diff --git a/components/annotated-source.tsx b/components/annotated-source.tsx
new file mode 100644
index 0000000..5c1abe3
--- /dev/null
+++ b/components/annotated-source.tsx
@@ -0,0 +1,192 @@
+import { IGACAnnotation } from '../services/gac-annotations';
+import Highlight from 'react-highlight';
+import { useRef, useState, useMemo, useLayoutEffect } from 'react';
+import ReactMarkdown from 'react-markdown/with-html';
+
+interface IAnnotatedSourceProps {
+ code: string;
+ annotations: IGACAnnotation[];
+}
+
+function annotationLines(annotation: IGACAnnotation) {
+ const result = [];
+ for (let line = annotation.line; line <= annotation.endLine; line++) {
+ result.push(line);
+ }
+ return result;
+}
+
+function AnnotationMarkers({ annotations }: { annotations: IGACAnnotation[] }) {
+ const result = [];
+ for (const annotation of annotations) {
+ for (const line of annotationLines(annotation)) {
+ result[line - 1] = (
+
+
+
+ );
+ }
+ }
+ for (let line = 0; line < result.length; line++) {
+ result[line] = result[line] ??
;
+ }
+ return <>{result}>;
+}
+
+export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) {
+ const codeBoxRef = useRef(null);
+ const markersRef = useRef(null);
+
+ const [activeAnnotation, setActiveAnnotation] = useState(null);
+
+ const highlightedCode = useMemo(() => {code}, [code]);
+ const annotatedLines = useMemo(() => {
+ const result = new Map();
+ for (const annotation of annotations) {
+ for (const line of annotationLines(annotation)) {
+ result.set(line, annotation);
+ }
+ }
+ return result;
+ }, annotations);
+
+ const [codeLineHeight, setCodeLineHeight] = useState(0);
+ const codeLineOffset = (line: number) => (line - 1) * codeLineHeight;
+ useLayoutEffect(() => {
+ const measure = () =>
+ setCodeLineHeight(
+ markersRef.current?.firstElementChild?.getBoundingClientRect()?.height || 0,
+ );
+ measure();
+ window.addEventListener('resize', measure);
+ return () => window.removeEventListener('resize', measure);
+ }, [markersRef.current]);
+
+ const handleMouseOver = (e: React.MouseEvent) => {
+ const codeBox = codeBoxRef.current;
+ if (!codeBox) {
+ return;
+ }
+ const { target } = e;
+ if (target instanceof HTMLElement && target.closest('.annotation-info')) {
+ return;
+ }
+ if (codeLineHeight) {
+ const line = Math.floor((e.pageY - codeBox.offsetTop) / codeLineHeight) + 1;
+ setActiveAnnotation(annotatedLines.get(line) ?? null);
+ }
+ };
+
+ const annotationOffset = codeLineOffset(activeAnnotation?.line ?? 1);
+ return (
+ setActiveAnnotation(null)}
+ ref={codeBoxRef}
+ >
+
+
+
+
+
+
+ {highlightedCode}
+ {activeAnnotation && (
+
+ (url.startsWith('#') ? '' : '_blank')}
+ />
+
+ )}
+
+
+ );
+}
diff --git a/content/simon/sketch.ino b/content/simon/sketch.ino
index 02b2dc3..ac63d8d 100644
--- a/content/simon/sketch.ino
+++ b/content/simon/sketch.ino
@@ -3,37 +3,70 @@
Copyright (C) 2016, Uri Shaked
- Licensed under the MIT License.
+ Released under the MIT License.
*/
+/* gac: `pitches.h` defines constants for the different music notes */
#include "pitches.h"
/* Constants - define pin numbers for LEDs,
buttons and speaker, and also the game tones: */
-char ledPins[] = {9, 10, 11, 12};
-char buttonPins[] = {2, 3, 4, 5};
+/* gac:start
+ ✅ Define pin numbers as constants at the beginning of the program
+*/
+const byte ledPins[] = {9, 10, 11, 12};
+const byte buttonPins[] = {2, 3, 4, 5};
#define SPEAKER_PIN 8
+/* gac:end */
+/* gac:
+ ✅ Defining the max game length as a constant makes it clear
+ what this "magic" number controls. */
#define MAX_GAME_LENGTH 100
-int gameTones[] = { NOTE_G3, NOTE_C4, NOTE_E4, NOTE_G5};
+/* gac: The tone for each button is stored in an array,
+ so we can easily match each LED, button and their corresponding
+ tone by looking at a specific index of the `ledPins`, `buttonPins`,
+ and `gameTones` arrays.
+*/
+const int gameTones[] = { NOTE_G3, NOTE_C4, NOTE_E4, NOTE_G5};
/* Global variales - store the game state */
+/* gac:start
+ ✅ It's a good pratice to initialize all variables to a default value when
+ delcaring them.
+
+ We use `gameSequence` to keep track of the current color sequence
+ that the user has to repeat, and `gameIndex` tells us how long the
+ sequence is, so we know how many cells of the array we should look at.
+*/
byte gameSequence[MAX_GAME_LENGTH] = {0};
byte gameIndex = 0;
+/* gac:end */
/**
Set up the Arduino board and initialize Serial communication
*/
void setup() {
Serial.begin(9600);
- for (int i = 0; i < 4; i++) {
+ /* gac:start
+ Defining the pins for the LEDs and Buttons in an array allows
+ us to initialize them using a `for` loop, instead of calling
+ the `pinMode()` function for each individual button / LED.
+ */
+ for (byte i = 0; i < 4; i++) {
pinMode(ledPins[i], OUTPUT);
pinMode(buttonPins[i], INPUT_PULLUP);
}
+ /* gac:end */
pinMode(SPEAKER_PIN, OUTPUT);
// The following line primes the random number generator.
// It assumes pin A0 is floating (disconnected):
+ /* gac: In arduino, the `random()` function will return the sequence
+ of numbers every time you start your program, unless you call
+ `randomSeed()` with a different value each time. To learn more about
+ this trick, check out the ["Making random() more Random"
+ video](https://www.youtube.com/watch?v=FwnXqZB2eo8). */
randomSeed(analogRead(A0));
}
@@ -53,7 +86,7 @@ void lightLedAndPlaySound(byte ledIndex) {
*/
void playSequence() {
for (int i = 0; i < gameIndex; i++) {
- char currentLed = gameSequence[i];
+ byte currentLed = gameSequence[i];
lightLedAndPlaySound(currentLed);
delay(50);
}
@@ -64,8 +97,8 @@ void playSequence() {
and returns the index of that button
*/
byte readButton() {
- for (;;) {
- for (int i = 0; i < 4; i++) {
+ while (true) {
+ for (byte i = 0; i < 4; i++) {
byte buttonPin = buttonPins[i];
if (digitalRead(buttonPin) == LOW) {
return i;
@@ -83,6 +116,7 @@ void gameOver() {
Serial.println(gameIndex - 1);
gameIndex = 0;
delay(200);
+
// Play a Wah-Wah-Wah-Wah sound
tone(SPEAKER_PIN, NOTE_DS5);
delay(300);
@@ -90,7 +124,7 @@ void gameOver() {
delay(300);
tone(SPEAKER_PIN, NOTE_CS5);
delay(300);
- for (int i = 0; i < 200; i++) {
+ for (byte i = 0; i < 200; i++) {
tone(SPEAKER_PIN, NOTE_C5 + (i % 20 - 10));
delay(5);
}
@@ -104,8 +138,8 @@ void gameOver() {
*/
void checkUserSequence() {
for (int i = 0; i < gameIndex; i++) {
- char expectedButton = gameSequence[i];
- char actualButton = readButton();
+ byte expectedButton = gameSequence[i];
+ byte actualButton = readButton();
lightLedAndPlaySound(actualButton);
if (expectedButton == actualButton) {
/* good */
@@ -142,6 +176,21 @@ void loop() {
// Add a random color to the end of the sequence
gameSequence[gameIndex] = random(0, 4);
gameIndex++;
+ /* gac:start
+ ✅ Array boundary check
+
+ The C++ language does not protect us from accessing data beyond the
+ end of the array. Therefore, our code must always check that we are
+ still within the array boundaries. Otherwise, we'll get unexpected
+ behavior and the program may crash.
+
+ In this case, we make sure that `gameIndex` always stays below
+ `MAX_GAME_LENGTH`, which is the size of the `gameSequence` array.
+ */
+ if (gameIndex >= MAX_GAME_LENGTH) {
+ gameIndex = MAX_GAME_LENGTH - 1;
+ }
+ /* gac:end */
playSequence();
checkUserSequence();
diff --git a/pages/api/download-project/[project].ts b/pages/api/download-project/[project].ts
index d7d0fad..248ac85 100644
--- a/pages/api/download-project/[project].ts
+++ b/pages/api/download-project/[project].ts
@@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { promisify } from 'util';
import ZipStream from 'zip-stream';
import { getProjectCode } from '../../../services/projects';
+import { extractCodeAnnotations } from '../../../services/gac-annotations';
export default async (req: NextApiRequest, res: NextApiResponse) => {
const {
@@ -17,7 +18,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
zip.pipe(res);
for (const file of await getProjectCode(projectId)) {
- await promisify(zip.entry).call(zip, file.code, { name: file.name });
+ const { code } = extractCodeAnnotations(file.code);
+ await promisify(zip.entry).call(zip, code, { name: file.name });
}
zip.finalize();
};
diff --git a/pages/projects/[id].tsx b/pages/projects/[id].tsx
index bdefa71..e28b3f2 100644
--- a/pages/projects/[id].tsx
+++ b/pages/projects/[id].tsx
@@ -3,12 +3,14 @@ import { GetStaticPaths, GetStaticProps } from 'next';
import Head from 'next/head';
import { ParsedUrlQuery } from 'querystring';
import React from 'react';
-import Highlight from 'react-highlight';
import ReactMarkdown from 'react-markdown/with-html';
+import { AnnotatedSource } from '../../components/annotated-source';
import { GlobalStyles } from '../../components/global-styles';
import { Header } from '../../components/header';
import { LinkIconButton } from '../../components/link-icon-button';
import { ISideNavLink, SideNav } from '../../components/side-nav';
+import { reportEvent } from '../../services/analytics';
+import { extractCodeAnnotations, IGACAnnotation } from '../../services/gac-annotations';
import {
getProject,
getProjectCode,
@@ -20,19 +22,22 @@ import { projectFileURL } from '../../services/urls';
import { HeadingRenderer } from '../../utils/heading-renderer';
import { headingToId } from '../../utils/heading-to-id';
import { extractHeadings } from '../../utils/markdown-utils';
-import { reportEvent } from '../../services/analytics';
interface ProjectPageParams extends ParsedUrlQuery {
id: string;
}
+interface IAnnotatedSourceFile extends IProjectSourceFile {
+ annotations: IGACAnnotation[];
+}
+
interface ProjectPageProps {
id: string;
name: string;
author?: string;
description?: string;
simulation: string | null;
- code: IProjectSourceFile[];
+ code: IAnnotatedSourceFile[];
text: string;
}
@@ -132,7 +137,7 @@ export default function ProjectPage(props: ProjectPageProps) {
{props.code.map((file) => (
{file.name}
- {file.code}
+
))}
{props.simulation && (
@@ -226,6 +231,10 @@ export const getStaticProps: GetStaticProps
throw new Error('Missing post id');
}
const project = await getProject(params.id);
+ const annotatedCode = (await getProjectCode(params.id)).map((sourceFile) => {
+ const { code, annotations } = extractCodeAnnotations(sourceFile.code);
+ return { ...sourceFile, code, annotations };
+ });
return {
props: {
id: params.id,
@@ -234,7 +243,7 @@ export const getStaticProps: GetStaticProps
description: project.description,
simulation: project.simulation ?? null,
text: await getProjectText(params.id),
- code: await getProjectCode(params.id),
+ code: annotatedCode,
},
};
};
diff --git a/services/gac-annotations.spec.ts b/services/gac-annotations.spec.ts
new file mode 100644
index 0000000..4c385f4
--- /dev/null
+++ b/services/gac-annotations.spec.ts
@@ -0,0 +1,82 @@
+import { extractCodeAnnotations } from './gac-annotations';
+
+describe('extractCodeAnnotations', () => {
+ it('should split the given source file into Code and GAC annotations', () => {
+ const input = `
+ /* gac: simple annotation */
+ int speakerPin = 7;
+
+ /* gac: This annotation applies for
+ a single line
+ */
+ /* This is a standard comment */
+ void setup() {}
+
+ /* gac:start
+ This is the loop function...
+ */
+ void loop() {
+
+ }
+ /* gac:end */
+ `;
+
+ const expectedCode = `
+ int speakerPin = 7;
+
+ /* This is a standard comment */
+ void setup() {}
+
+ void loop() {
+
+ }
+ `;
+
+ expect(extractCodeAnnotations(input)).toEqual({
+ code: expectedCode,
+ annotations: [
+ {
+ line: 2,
+ endLine: 2,
+ value: 'simple annotation',
+ },
+ {
+ line: 4,
+ endLine: 4,
+ value: 'This annotation applies for\na single line',
+ },
+ { line: 7, endLine: 9, value: 'This is the loop function...' },
+ ],
+ });
+ });
+
+ it('should handle CRLF in annotations', () => {
+ const input = '/* gac: hello\r\nworld */\r\nTest';
+
+ expect(extractCodeAnnotations(input)).toEqual({
+ code: 'Test',
+ annotations: [
+ {
+ line: 1,
+ endLine: 1,
+ value: 'hello\nworld',
+ },
+ ],
+ });
+ });
+
+ it('should correctly parse single line gac:start annotation', () => {
+ const input = '/*gac:start single line*/\nTest\nLine 2\n/* gac:end*/';
+
+ expect(extractCodeAnnotations(input)).toEqual({
+ code: 'Test\nLine 2',
+ annotations: [
+ {
+ line: 1,
+ endLine: 2,
+ value: 'single line',
+ },
+ ],
+ });
+ });
+});
diff --git a/services/gac-annotations.ts b/services/gac-annotations.ts
new file mode 100644
index 0000000..7e55cb7
--- /dev/null
+++ b/services/gac-annotations.ts
@@ -0,0 +1,74 @@
+export interface IGACAnnotation {
+ line: number;
+ endLine: number;
+ value: string;
+}
+
+const keywords = {
+ gac: 'gac:',
+ gacStart: 'gac:start',
+ gacEnd: 'gac:end',
+} as const;
+
+export function extractCodeAnnotations(source: string) {
+ const code = [];
+ const annotations: IGACAnnotation[] = [];
+ let lineNumber = 1;
+ let currentIndent = 0;
+ let currentAnnotation: IGACAnnotation | null = null;
+ let insideComment = false;
+ let insideSection = false;
+ for (const line of source.split('\n')) {
+ const trimmed = line.trim();
+ if (trimmed.startsWith('/*')) {
+ const firstWord = trimmed.substr(2).trim().split(/[ *]/)[0];
+ if (firstWord === keywords.gac || firstWord === keywords.gacStart) {
+ insideComment = true;
+ insideSection = firstWord === keywords.gacStart;
+ const content = trimmed
+ .substr(trimmed.indexOf(firstWord) + firstWord.length)
+ .trim()
+ .replace(/\s*\*\/.*$/s, '');
+ currentAnnotation = {
+ line: lineNumber,
+ endLine: lineNumber,
+ value: content,
+ };
+ annotations.push(currentAnnotation);
+ if (trimmed.includes('*/')) {
+ insideComment = false;
+ if (!insideSection) {
+ currentAnnotation = null;
+ }
+ }
+ currentIndent = line.indexOf(firstWord);
+ continue;
+ }
+ if (firstWord === keywords.gacEnd && currentAnnotation) {
+ currentAnnotation.endLine = lineNumber - 1;
+ currentAnnotation = null;
+ continue;
+ }
+ }
+ if (insideComment && currentAnnotation) {
+ const content = line
+ .replace(new RegExp(`^ {0,${currentIndent}}`), '')
+ .replace(/\s*\*\/.*$/s, '');
+ currentAnnotation.value += '\n' + content;
+ if (trimmed.startsWith('*/') || trimmed.endsWith('*/')) {
+ currentAnnotation.value = currentAnnotation.value.trim();
+ insideComment = false;
+ if (!insideSection) {
+ currentAnnotation = null;
+ }
+ }
+ continue;
+ }
+ code.push(line);
+ lineNumber++;
+ }
+ return {
+ code: code.join('\n'),
+ annotations,
+ };
+}