diff --git a/.env.sample b/.env.sample
index 2376572..1f599ba 100644
--- a/.env.sample
+++ b/.env.sample
@@ -33,4 +33,3 @@ REPO_AWS_SECRET_ACCESS_KEY_PRODUCTION=
REPO_AWS_SECRET_ACCESS_KEY_STAGING=
REPO_SLACK_BOT_TOKEN=
REPO_SLACK_WEBHOOK_URL=
-
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..944880f
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+3.2.0
diff --git a/Gemfile b/Gemfile
index c2e7e2c..33cf0fd 100644
--- a/Gemfile
+++ b/Gemfile
@@ -32,7 +32,7 @@ gem 'aws-sdk-s3', '~> 1.64'
gem 'concurrent-ruby', '~> 1.1'
# Interacting with github
-gem 'octokit', '~> 6'
+gem 'octokit', '~> 7'
# Interface with libsodium
gem 'rbnacl'
@@ -45,6 +45,9 @@ gem 'sass-embedded', '~> 1.58'
gem 'sinatra', '~> 3'
gem 'thin'
+# Pinning google-protobuf so that it continues to build on ARM devices
+gem 'google-protobuf', '=3.22.0'
+
# Controlling Chrome to create snapshots
gem 'ferrum'
diff --git a/Gemfile.lock b/Gemfile.lock
index 317f7c0..6f161c1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -14,35 +14,36 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- activemodel (7.0.7.1)
- activesupport (= 7.0.7.1)
- activesupport (7.0.7.1)
+ activemodel (7.0.8)
+ activesupport (= 7.0.8)
+ activesupport (7.0.8)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
- addressable (2.8.4)
+ addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.2.0)
- aws-partitions (1.747.0)
- aws-sdk-core (3.171.0)
+ aws-partitions (1.827.0)
+ aws-sdk-core (3.183.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
- aws-sdk-kms (1.63.0)
- aws-sdk-core (~> 3, >= 3.165.0)
+ aws-sdk-kms (1.71.0)
+ aws-sdk-core (~> 3, >= 3.177.0)
aws-sigv4 (~> 1.1)
- aws-sdk-s3 (1.120.1)
- aws-sdk-core (~> 3, >= 3.165.0)
+ aws-sdk-s3 (1.135.0)
+ aws-sdk-core (~> 3, >= 3.181.0)
aws-sdk-kms (~> 1)
- aws-sigv4 (~> 1.4)
- aws-sigv4 (1.5.2)
+ aws-sigv4 (~> 1.6)
+ aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
backport (1.2.0)
+ base64 (0.1.1)
benchmark (0.2.1)
- cli-ui (2.1.0)
+ cli-ui (2.2.3)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.2.2)
@@ -53,24 +54,25 @@ GEM
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
eventmachine (1.2.7)
- faraday (2.7.4)
+ faraday (2.7.11)
+ base64
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-net_http (3.0.2)
- faraday-retry (2.1.0)
+ faraday-retry (2.2.0)
faraday (~> 2.0)
- ferrum (0.13)
+ ferrum (0.14)
addressable (~> 2.5)
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (>= 0.6, < 0.8)
- ffi (1.15.5)
+ ffi (1.16.1)
formatador (1.1.0)
git (1.18.0)
addressable (~> 2.8)
rchardet (~> 1.8)
google-protobuf (3.22.0)
- guard (2.18.0)
+ guard (2.18.1)
formatador (>= 0.2.4)
listen (>= 2.7, < 4.0)
lumberjack (>= 1.0.12, < 2.0)
@@ -88,47 +90,49 @@ GEM
http_parser.rb (0.8.0)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
- jaro_winkler (1.5.4)
+ jaro_winkler (1.5.6)
jmespath (1.6.2)
json (2.6.3)
kramdown (2.4.0)
rexml
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
+ language_server-protocol (3.17.0.3)
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
- lumberjack (1.2.8)
+ lumberjack (1.2.9)
method_source (1.0.0)
mini_magick (4.12.0)
- mini_portile2 (2.8.1)
- minitest (5.19.0)
+ mini_portile2 (2.8.4)
+ minitest (5.20.0)
multi_json (1.15.0)
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
nenv (0.3.0)
- nokogiri (1.14.3)
- mini_portile2 (~> 2.8.0)
+ nokogiri (1.15.4)
+ mini_portile2 (~> 2.8.2)
racc (~> 1.4)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
- octokit (6.1.1)
+ octokit (7.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
- parallel (1.22.1)
- parser (3.2.2.0)
+ parallel (1.23.0)
+ parser (3.2.2.3)
ast (~> 2.4.1)
+ racc
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
- public_suffix (5.0.1)
- racc (1.6.2)
- rack (2.2.6.4)
+ public_suffix (5.0.3)
+ racc (1.7.1)
+ rack (2.2.8)
rack-livereload (0.5.1)
rack
- rack-protection (3.0.6)
- rack
+ rack-protection (3.1.0)
+ rack (~> 2.2, >= 2.2.4)
rack-test (2.1.0)
rack (>= 1.3)
rainbow (3.1.1)
@@ -140,35 +144,37 @@ GEM
ffi
rbs (2.8.4)
rchardet (1.8.0)
- regexp_parser (2.7.0)
+ regexp_parser (2.8.1)
reverse_markdown (2.1.1)
nokogiri
- rexml (3.2.5)
- rubocop (1.50.1)
+ rexml (3.2.6)
+ rubocop (1.56.3)
+ base64 (~> 0.1.1)
json (~> 2.3)
+ language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
- parser (>= 3.2.0.0)
+ parser (>= 3.2.2.3)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
- rubocop-ast (>= 1.28.0, < 2.0)
+ rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
- rubocop-ast (1.28.0)
+ rubocop-ast (1.29.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
- sass-embedded (1.62.0)
+ sass-embedded (1.62.1)
google-protobuf (~> 3.21)
rake (>= 10.0.0)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
shellany (0.0.1)
- sinatra (3.0.6)
+ sinatra (3.1.0)
mustermann (~> 3.0)
rack (~> 2.2, >= 2.2.4)
- rack-protection (= 3.0.6)
+ rack-protection (= 3.1.0)
tilt (~> 2.0)
slack-notifier (2.4.0)
solargraph (0.49.0)
@@ -191,17 +197,17 @@ GEM
daemons (~> 1.0, >= 1.0.9)
eventmachine (~> 1.0, >= 1.0.4)
rack (>= 1, < 3)
- thor (1.2.1)
- tilt (2.1.0)
+ thor (1.2.2)
+ tilt (2.3.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.4.2)
webrick (1.8.1)
- websocket-driver (0.7.5)
+ websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
yard (0.9.34)
- zeitwerk (2.6.7)
+ zeitwerk (2.6.11)
PLATFORMS
ruby
@@ -217,11 +223,12 @@ DEPENDENCIES
faraday-retry
ferrum
git
+ google-protobuf (= 3.22.0)
guard (~> 2, >= 2.16.2)
guard-livereload
levenshtein-ffi!
mini_magick
- octokit (~> 6)
+ octokit (~> 7)
rack-livereload
rack-test
rbnacl
@@ -236,4 +243,4 @@ DEPENDENCIES
zeitwerk (~> 2.3)
BUNDLED WITH
- 2.4.7
+ 2.4.19
diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb
new file mode 100644
index 0000000..dc503e5
--- /dev/null
+++ b/app/commands/content_module_cli.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+# CLI for managing content module of a multi model learning path
+
+class ContentModuleCli < Thor
+ desc 'render', 'renders content modules'
+ option :module_file, type: :string, desc: 'Location of the module.yaml file'
+ option :local, type: :boolean
+ def render
+ runner.render_content_module(module_file: options['module_file'], local: options['local'])
+ end
+
+ desc 'serve', 'starts local preview server'
+ option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)'
+ def serve
+ fork do
+ if options[:dev]
+ Guard.start(no_interactions: true)
+ else
+ Guard.start(guardfile_contents: content_module_guardfile, watchdir: '/data/src', no_interactions: true)
+ end
+ end
+ RoblesContentModuleServer.run!
+ end
+
+ desc 'lint [MODULE_FILE]', 'runs a selection of linters on the module'
+ option :module_file, type: :string, desc: 'Location of the module.yaml file'
+ method_options 'without-version': :boolean, aliases: '-e', default: false, desc: 'Run linting without git branch naming check'
+ method_options silent: :boolean, aliases: '-s', default: false, desc: 'Hide all output'
+ def lint
+ output = runner.lint_content_module(module_file: options['module_file'], options:)
+ exit 1 unless output.validated || ENVIRONMENT == 'staging'
+ end
+
+ desc 'circulate [MODULE_FILE]', 'renders and circulates a content module'
+ option :module_file, type: :string, desc: 'Location of the module.yaml file'
+ def circulate
+ runner.circulate_content_module(module_file: options['module_file'])
+ end
+
+ desc 'slides [MODULE_FILE]', 'generates slides to be inserted at beginning of video'
+ option :module_file, type: :string, desc: 'Location of the module.yaml file'
+ option :app_host, type: :string, default: 'app', desc: 'Hostname of host running robles app server'
+ option :app_port, type: :string, default: '4567', desc: 'Port of host running robles app server'
+ option :snapshot_host, type: :string, default: 'snapshot', desc: 'Hostname of host running headless chrome'
+ option :snapshot_port, type: :string, default: '3000', desc: 'Port of host running headless chrome'
+ option :out_dir, type: :string, default: '/data/src/artwork/slides', desc: 'Location to save the output slides'
+ def slides
+ module_file = options.fetch('module_file', runner.default_module_file)
+ parser = Parser::Circulate.new(file: module_file)
+ content_module = parser.parse
+ options.delete('module_file')
+ args = options.merge(data: content_module.lessons.flat_map(&:segments), snapshot_host: 'localhost').symbolize_keys
+ snapshotter = Snapshotter::Slides.new(**args)
+ snapshotter.generate
+ end
+
+
+ desc 'serve', 'starts local preview server'
+ option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)'
+ def serve
+ fork do
+ if options[:dev]
+ Guard.start(no_interactions: true)
+ else
+ Guard.start(guardfile_contents: content_module_guardfile, watchdir: './', no_interactions: true)
+ end
+ end
+ RoblesContentModuleServer.run!
+ end
+
+ private
+
+ def runner
+ Runner::Base.runner
+ end
+
+ def content_module_guardfile
+ <<~GUARDFILE
+ guard 'livereload' do
+ watch(%r{[a-zA-Z0-9-_]+.yaml$})
+ end
+ GUARDFILE
+ end
+end
diff --git a/app/commands/robles_cli.rb b/app/commands/robles_cli.rb
index db79ee1..86863ac 100644
--- a/app/commands/robles_cli.rb
+++ b/app/commands/robles_cli.rb
@@ -7,37 +7,17 @@ def self.exit_on_failure?
true
end
- desc 'book SUBCOMMAND ...ARGS', 'manage publication of books'
+ desc 'book [SUBCOMMAND] ...ARGS', 'manage publication of books'
subcommand 'book', BookCli
- desc 'video SUBCOMMAND ...ARGS', 'manage publication of videos'
+ desc 'video [SUBCOMMAND] ...ARGS', 'manage publication of videos'
subcommand 'video', VideoCli
- desc 'pablo SUBCOMMAND ...ARGS', 'manage publication of pablo'
- subcommand 'pablo', PabloCli
-
- ## We leave these in for now--they're deprecated. They should be removed later
- desc 'serve', '[DEPRECATED: use `robles book serve` instead] starts local preview server'
- option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)'
- def serve
- fork do
- if options[:dev]
- Guard.start(no_interactions: true)
- else
- Guard.start(guardfile_contents: book_guardfile, watchdir: '/data/src', no_interactions: true)
- end
- end
- RoblesBookServer.run!
- end
+ desc 'module [SUBCOMMAND] ...ARGS', 'manage publication of content modules'
+ subcommand 'module', ContentModuleCli
- desc 'lint [PUBLISH_FILE]', '[DEPRECATED: use `robles book lint` instead] runs a selection of linters on the book'
- option :'publish-file', type: :string, desc: 'Location of the publish.yaml file'
- method_options 'without-edition': :boolean, aliases: '-e', default: false, desc: 'Run linting without git branch naming check'
- method_options silent: :boolean, aliases: '-s', default: false, desc: 'Hide all output'
- def lint
- output = runner.lint_book(publish_file: options['publish_file'], options:)
- exit 1 unless output.validated || ENVIRONMENT == 'staging'
- end
+ desc 'pablo [SUBCOMMAND] ...ARGS', 'manage publication of pablo'
+ subcommand 'pablo', PabloCli
private
diff --git a/app/commands/video_cli.rb b/app/commands/video_cli.rb
index bec04f0..9f79e11 100644
--- a/app/commands/video_cli.rb
+++ b/app/commands/video_cli.rb
@@ -7,7 +7,6 @@ class VideoCli < Thor
option :local, type: :boolean
def render
video_course = runner.render_video_course(release_file: options['release_file'], local: options['local'])
- puts video_course.to_json
end
desc 'serve', 'starts local preview server'
@@ -58,7 +57,7 @@ def slides
release_file = options.fetch('release_file', runner.default_release_file)
parser = Parser::Release.new(file: release_file)
video_course = parser.parse
- args = options.merge(video_course:).symbolize_keys
+ args = options.merge(data: video_course.parts.flat_map(&:episodes)).symbolize_keys
snapshotter = Snapshotter::Slides.new(**args)
snapshotter.generate
end
diff --git a/app/lib/api/alexandria/content_module_uploader.rb b/app/lib/api/alexandria/content_module_uploader.rb
new file mode 100644
index 0000000..3ebf085
--- /dev/null
+++ b/app/lib/api/alexandria/content_module_uploader.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Api
+ module Alexandria
+ # Allow uploading of content_modules to alexandria
+ class ContentModuleUploader
+ include Util::Logging
+
+ attr_reader :content_module
+
+ PUBLISH_URL = '/api/content_modules/publish'
+
+ def self.upload(content_module)
+ new(content_module).upload
+ end
+
+ def initialize(content_module)
+ @content_module = content_module
+ end
+
+ def upload
+ conn.post do |req|
+ req.url api_uri
+ req.body = payload
+ end
+ end
+
+ private
+
+ def api_uri
+ path = [
+ ALEXANDRIA_BASE_URL,
+ PUBLISH_URL
+ ].join
+
+ URI(path)
+ end
+
+ def conn
+ @conn ||= Faraday.new(headers: { 'Content-Type' => 'application/json' }) do |faraday|
+ faraday.response(:logger, logger) do |logger|
+ logger.filter(/(Token token=\\")(\w+)/, '\1[REMOVED]')
+ end
+ faraday.response(:raise_error)
+ faraday.adapter(Faraday.default_adapter)
+ faraday.request(:authorization, 'Bearer', ALEXANDRIA_SERVICE_API_TOKEN)
+ faraday.request(:retry)
+ end
+ end
+
+ def payload
+ { content_module: }.to_json
+ end
+ end
+ end
+end
diff --git a/app/lib/image_provider/content_module_extractor.rb b/app/lib/image_provider/content_module_extractor.rb
new file mode 100644
index 0000000..b2cc19a
--- /dev/null
+++ b/app/lib/image_provider/content_module_extractor.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module ImageProvider
+ # Extract all images from a content module
+ class ContentModuleExtractor
+ attr_reader :content_module, :images
+
+ def initialize(content_module)
+ @content_module = content_module
+ @images = []
+ end
+
+ def extract
+ @images = image_paths.map do |path|
+ Image.with_representations({ local_url: path[:absolute_path], uploaded_image_root_path: }, variants: path[:variants])
+ end
+ end
+
+ def extract_images_from_markdown(file)
+ MarkdownImageExtractor.images_from(file) if file && File.exist?(file)
+ end
+
+ def uploaded_image_root_path
+ "content_module/#{Digest::SHA2.hexdigest(content_module.shortcode)}/images"
+ end
+
+ def image_paths
+ content_module.lessons.map do |lesson|
+ from_segments = lesson.segments.map do |segment|
+ next unless segment.respond_to?(:markdown_file)
+
+ extract_images_from_markdown(segment.markdown_file) + segment.image_attachment_paths
+ end
+
+ from_segments + lesson.image_attachment_paths
+ end.flatten.compact.uniq + content_module.image_attachment_paths
+ end
+ end
+end
diff --git a/app/lib/linting/content_module_linter.rb b/app/lib/linting/content_module_linter.rb
new file mode 100644
index 0000000..8c96176
--- /dev/null
+++ b/app/lib/linting/content_module_linter.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module Linting
+ # Overall linter that combines all other linters
+ class ContentModuleLinter # rubocop:disable Metrics/ClassLength
+ include Util::PathExtraction
+ include Util::Logging
+ include Linting::FileExistenceChecker
+ include Linting::YamlSyntaxChecker
+
+ attr_reader :annotations, :output_details
+
+ def initialize(file:)
+ super
+ Linting::Annotation.root_directory = Pathname.new(file).dirname
+ end
+
+ def lint(options: {})
+ @annotations = []
+ lint_with_ui(options:, show_ui: !options['silent'])
+ output
+ end
+
+ def lint_with_ui(options:, show_ui: true) # rubocop:disable Metrics/AbcSize
+ with_spinner(title: 'Checking {{bold:module.yaml}} exists', show: show_ui) do
+ check_module_file_exists
+ end
+ return if output_details.present?
+
+ with_spinner(title: 'Checking {{bold:module.yaml}} is valid YAML', show: show_ui) do
+ check_module_file_valid_yaml
+ end
+ return if output_details.present?
+
+ with_spinner(title: 'Validating metadata in {{bold:module.yaml}}', show: show_ui) do
+ annotations.concat(Linting::ContentModuleMetadataLinter.new(file:).lint(options:))
+ end
+ return unless annotations.blank?
+
+ with_spinner(title: 'Attempting to parse content module', show: show_ui) do
+ content_module
+ end
+ return unless annotations.blank?
+
+ with_spinner(title: 'Validating data models', show: show_ui) do
+ annotations.concat(Linting::Validations::ContentModule.new(content_module:, file:).lint)
+ end
+ end
+
+ def with_spinner(title:, show: true, &block)
+ if show
+ CLI::UI::Spinner.spin(title, &block)
+ else
+ yield
+ end
+ end
+
+ def output
+ return Linting::Output.new(output_details.merge(annotations:)) if output_details.present?
+
+ if annotations.present?
+ Linting::Output.new(
+ title: 'robles Linting Failure',
+ summary: 'There was a problem with your content module repository',
+ text: 'Please check the individual file annotations for details',
+ annotations:,
+ validated: false
+ )
+ else
+ Linting::Output.new(
+ title: 'robles Linting Success',
+ summary: 'Your content module repo looks great',
+ text: 'I have nothing else to say here...',
+ validated: true
+ )
+ end
+ end
+
+ def check_module_file_exists
+ logger.debug "Checking for existence of #{file}"
+ return true if file_exists?(file)
+
+ @output_details = {
+ title: 'robles Linting Failure',
+ summary: 'Unable to locate the `module.yaml` file',
+ text: "There should be a `module.yaml` file in the root of your book repository. Looking here: #{file}",
+ validated: false
+ }
+ false
+ end
+
+ def check_module_file_valid_yaml
+ logger.debug "Checking for validity of #{file}"
+ error = valid_yaml?(file)
+ return true unless error
+
+ @output_details = {
+ title: 'robles Linting Failure',
+ summary: 'Unable to parse `module.yaml`',
+ text: "There was a problem parsing the `module.yaml` file. Check the indentation. Details:\n\n > #{error}",
+ validated: false
+ }
+ false
+ end
+
+ def content_module
+ @content_module ||= begin
+ parser = Parser::Circulate.new(file:)
+ parser.parse
+ end
+ rescue Parser::Error => e
+ line_number = (e.message.match(/at line (\d+)/)&.captures&.first&.to_i || 0) + 1
+ annotations.push(
+ Annotation.new(
+ absolute_path: e.file,
+ annotation_level: 'failure',
+ start_line: line_number,
+ end_line: line_number,
+ message: e.message,
+ title: 'Unable to parse content module.'
+ )
+ )
+ end
+ end
+end
diff --git a/app/lib/linting/content_module_metadata_linter.rb b/app/lib/linting/content_module_metadata_linter.rb
new file mode 100644
index 0000000..57deee2
--- /dev/null
+++ b/app/lib/linting/content_module_metadata_linter.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Linting
+ # Run through various metadata checks
+ class ContentModuleMetadataLinter
+ include Util::PathExtraction
+
+ def lint(options: {}) # rubocop:disable Metrics/AbcSize
+ [].tap do |annotations|
+ annotations.concat Linting::Metadata::CirculateAttributes.lint(file:, attributes: module_attributes)
+ annotations.concat Linting::Metadata::ModuleFile.lint(file:, attributes: module_attributes)
+ annotations.concat Linting::Metadata::CaptionsFile.lint(file:, attributes: module_attributes)
+ annotations.concat Linting::Metadata::AssessmentFile.lint(file:, attributes: module_attributes)
+ annotations.concat Linting::Metadata::BranchName.lint(file:, attributes: module_attributes, version_attribute: :version) unless options['without-version']
+ end
+ end
+
+ def module_attributes
+ @module_attributes ||= Psych.load_file(file, permitted_classes: [Date]).deep_symbolize_keys
+ end
+ end
+end
diff --git a/app/lib/linting/metadata/assessment_file.rb b/app/lib/linting/metadata/assessment_file.rb
index a66753f..ce8137f 100644
--- a/app/lib/linting/metadata/assessment_file.rb
+++ b/app/lib/linting/metadata/assessment_file.rb
@@ -23,12 +23,31 @@ def lint
# Find the script file references in each episode in the release.yaml
def file_path_list
- @file_path_list ||=
- attributes[:parts].flat_map do |part|
- part[:episodes].map do |episode|
- episode[:assessment_file]
- end
- end.compact
+ @file_path_list ||= content_module? ? module_assessments : video_course_assessments
+ end
+
+ def video_course_assessments
+ attributes[:parts].flat_map do |part|
+ part[:episodes].map do |episode|
+ episode[:assessment_file]
+ end
+ end.compact
+ end
+
+ def module_assessments
+ attributes[:lessons].flat_map do |lesson|
+ ModuleFile.new(file:, attributes:).segments(lesson).flat_map do |segment|
+ segment[:relative_path] if assessment?(segment)
+ end
+ end.compact
+ end
+
+ def content_module?
+ attributes.key?(:lessons)
+ end
+
+ def assessment?(segment)
+ segment[:type] == 'assessment'
end
# What kind of files are we looking for?
diff --git a/app/lib/linting/metadata/captions_file.rb b/app/lib/linting/metadata/captions_file.rb
index 4b9acb0..8a84911 100644
--- a/app/lib/linting/metadata/captions_file.rb
+++ b/app/lib/linting/metadata/captions_file.rb
@@ -5,6 +5,7 @@ module Metadata
# Check that captions file references point to actual files
class CaptionsFile
include Linting::Metadata::FileAttributeExistenceChecker
+ include Parser::MarkdownMetadata
attr_reader :file, :attributes
@@ -23,18 +24,46 @@ def lint
# Find the script file references in each episode in the release.yaml
def file_path_list
- @file_path_list ||=
- attributes[:parts].flat_map do |part|
- part[:episodes].map do |episode|
- episode[:captions_file]
- end
- end.compact
+ @file_path_list ||= content_module? ? module_captions : video_course_captions
+ end
+
+ def video_course_captions
+ attributes[:parts].flat_map do |part|
+ part[:episodes].map do |episode|
+ episode[:captions_file]
+ end
+ end.compact
+ end
+
+ # Caption file is part of markdown metadata for video and text
+ # And quiz being a yaml file is handled
+ def module_captions
+ caption_files = ModuleFile.new(file:, attributes:).file_path_list
+ caption_files.map do |file|
+ if yaml?(file)
+ load_yaml(File.read(file)).deep_symbolize_keys[:captions_file]
+ else
+ @markdown_metadata = nil
+ @path = file
+ markdown_metadata[:captions_file]
+ end
+ end.compact
end
# What kind of files are we looking for?
def file_description
'captions'
end
+
+ private
+
+ def yaml?(file)
+ ['.yaml', '.yml'].include?(file.extname)
+ end
+
+ def content_module?
+ attributes[:lessons].present?
+ end
end
end
end
diff --git a/app/lib/linting/metadata/circulate_attributes.rb b/app/lib/linting/metadata/circulate_attributes.rb
new file mode 100644
index 0000000..7b594c4
--- /dev/null
+++ b/app/lib/linting/metadata/circulate_attributes.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Linting
+ module Metadata
+ # Check for the required attributes in the publish.yaml file
+ class CirculateAttributes
+ REQUIRED_ATTRIBUTES = %i[shortcode version title description_md released_at authors lessons materials_url
+ version_description difficulty platform language editor professional
+ short_description domains categories ].freeze
+
+ attr_reader :file, :attributes
+
+ def self.lint(file:, attributes:)
+ new(file:, attributes:).lint
+ end
+
+ def initialize(file:, attributes:)
+ @file = file
+ @attributes = attributes
+ end
+
+ def lint
+ (REQUIRED_ATTRIBUTES - attributes.keys).map do |missing_key|
+ Annotation.new(
+ start_line: 1,
+ end_line: 1,
+ absolute_path: file,
+ annotation_level: 'failure',
+ message: "`module.yaml` should include a top-level `#{missing_key}` attribute.`",
+ title: 'Missing required attribute'
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/lib/linting/metadata/module_file.rb b/app/lib/linting/metadata/module_file.rb
new file mode 100644
index 0000000..298eb4f
--- /dev/null
+++ b/app/lib/linting/metadata/module_file.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Linting
+ module Metadata
+ # Check that script file references point to actual files
+ class ModuleFile
+ include Linting::Metadata::FileAttributeExistenceChecker
+
+ attr_reader :file, :attributes
+
+ def self.lint(file:, attributes:)
+ new(file:, attributes:).lint
+ end
+
+ def initialize(file:, attributes:)
+ @file = file
+ @attributes = attributes
+ end
+
+ def lint
+ file_attribute_annotations
+ end
+
+ # Find the script file references in each episode in the release.yaml
+ def file_path_list
+ @file_path_list ||=
+ attributes[:lessons].flat_map do |lesson|
+ segment_files(lesson)
+ end.compact
+ end
+
+ def segments(lesson)
+ @file_path = Pathname.new(file).dirname.join(lesson[:segments_path])
+ lesson_data = Psych.load_file(@file_path, permitted_classes: [Date]).deep_symbolize_keys
+ lesson_data[:segments].map do |segment|
+ segment[:relative_path] = Pathname.new(@file_path).dirname.join(segment[:path])
+ segment
+ end
+ end
+
+ def segment_files(lesson)
+ segments(lesson).map { |segment| segment[:relative_path] }
+ end
+
+ # What kind of files are we looking for?
+ def file_description
+ 'script'
+ end
+ end
+ end
+end
diff --git a/app/lib/linting/validations/content_module.rb b/app/lib/linting/validations/content_module.rb
new file mode 100644
index 0000000..a5b2d20
--- /dev/null
+++ b/app/lib/linting/validations/content_module.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module Linting
+ module Validations
+ # Run the model validations for a VideoCourse and child objects
+ class ContentModule
+ attr_accessor :content_module, :annotations, :file
+
+ def initialize(content_module:, file:)
+ @content_module = content_module
+ @file = file
+ end
+
+ def lint # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ [].tap do |annotations|
+ content_module.lessons.each do |lesson|
+ lesson.segments.each do |segment|
+ if segment.is_a?(Video)
+ annotations.concat(validate_children(segment, :authors))
+ elsif segment.is_a?(Assessment::Quiz)
+ segment.questions.each do |question|
+ context = "Assessment::Quiz (#{segment&.validation_name || 'unknown'})"
+ annotations.concat(validate_children(question, :choices, context:))
+ end
+
+ annotations.concat(validate_children(segment, :questions))
+ end
+ end
+
+ annotations.concat(validate_children(lesson, :segments))
+ end
+
+ annotations.concat(validate_children(content_module, :authors))
+ annotations.concat(validate_children(content_module, :lessons))
+
+ annotations.concat(annotations_from_errors(content_module)) unless content_module.valid?
+ end.compact.reverse
+ end
+
+ def validate_children(object, attribute, context: nil)
+ Array.wrap(object.send(attribute)).flat_map do |child|
+ next if child.valid?
+
+ context = "#{context}\n#{object.class} (#{object&.validation_name || 'unknown'})"
+ annotations_from_errors(child, context:)
+ end.compact
+ end
+
+ def annotations_from_errors(object, context: nil)
+ title = "#{object.class} (#{object&.validation_name || 'unknown'})"
+ object.errors.full_messages.map do |error|
+ Linting::Annotation.new(
+ start_line: 0,
+ end_line: 0,
+ absolute_path: file,
+ annotation_level: 'failure',
+ message: "#{context}\n#{title}: #{error}",
+ title: "#{title} Validation Error"
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/lib/parser/circulate.rb b/app/lib/parser/circulate.rb
new file mode 100644
index 0000000..6828017
--- /dev/null
+++ b/app/lib/parser/circulate.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Parser
+ # Parses a release.yaml file, and returns a VideoCourse model object
+ class Circulate
+ include Util::PathExtraction
+ include Util::GitHashable
+
+ attr_reader :video_course
+
+ def parse
+ load_course
+ apply_additional_metadata
+ video_course
+ end
+
+ def load_course
+ @video_course = Parser::ContentModule.new(file:).parse
+ end
+
+ def apply_additional_metadata
+ video_course.root_path = root_directory
+ end
+ end
+end
diff --git a/app/lib/parser/content_module.rb b/app/lib/parser/content_module.rb
new file mode 100644
index 0000000..1341f31
--- /dev/null
+++ b/app/lib/parser/content_module.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module Parser
+ # Parse a symbolised hash into a ContentModule
+ class ContentModule
+ include Parser::SimpleAttributes
+ include Util::PathExtraction
+ include Util::GitHashable
+
+ VALID_SIMPLE_ATTRIBUTES = %i[shortcode version version_description title
+ description_md short_description released_at materials_url
+ professional difficulty platform language editor domains
+ categories who_is_this_for_md covered_concepts_md git_commit_hash
+ card_artwork_image featured_banner_image twitter_card_image
+ access_personal access_team].freeze
+
+ attr_accessor :content_module
+
+ def parse
+ lessons = metadata[:lessons].map.with_index do |lesson, idx|
+ parse_lesson(lesson, idx)
+ end
+
+ @content_module = ::ContentModule.new(lessons:)
+ apply_segment_ordinals
+ load_authors
+ apply_additional_metadata
+ content_module
+ end
+
+ def metadata
+ @metadata = load_yaml_file(file).merge(git_commit_hash: git_hash)
+ end
+
+ def parse_lesson(metadata, index)
+ segments = parse_segments(metadata[:segments_path])
+
+ Lesson.new(ordinal: index + 1, segments:).tap do |lesson|
+ lesson_metadata = load_yaml_file(apply_path((metadata[:segments_path])))
+ LessonMetadata.new(lesson, lesson_metadata).apply!
+ end
+ end
+
+ def parse_segments(segment_yaml_file)
+ lesson_path = segment_yaml_file.split('/').first
+ lesson = load_yaml_file(apply_path(segment_yaml_file))
+
+ lesson[:segments].map do |segment|
+ segment_path = "#{lesson_path}/#{segment[:path]}"
+ send("parse_#{segment[:type]}".to_sym, segment_path)
+ end
+ end
+
+ def parse_text(file)
+ markdown_file = apply_path(file)
+
+ Text.new(markdown_file:, root_path: Pathname.new(markdown_file).dirname.to_s).tap do |text|
+ TextMetadata.new(text).apply!
+ end
+ end
+
+ def parse_video(file) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ script_file = apply_path(file) if file.present?
+ raise Parser::Error.new(file:, msg: "Script file (#{file}}) not found") if script_file.present? && !File.file?(script_file)
+
+ root_path = Pathname.new(script_file).dirname.to_s if script_file.present?
+ captions_file = apply_path(metadata[:captions_file]) if metadata[:captions_file].present?
+ raise Parser::Error.new(file:, msg: "Captions file (#{metadata[:captions_file]}) not found") if captions_file.present? && !File.file?(captions_file)
+
+ metadata[:captions_file] = captions_file if captions_file.present?
+ Video.new(script_file:, root_path:).tap do |video|
+ VideoMetadata.new(video, metadata).apply!
+ end
+ end
+
+ def parse_assessment(file)
+ assessment_file = apply_path(file) if file.present?
+ raise Parser::Error.new(file:, msg: "Assessment file (#{file}}) not found") if assessment_file.present? && !File.file?(assessment_file)
+
+ assessment_metadata = load_yaml_file(assessment_file)
+
+ Assessment.create(assessment_metadata).tap do |assessment|
+ AssessmentMetadata.new(assessment, assessment_metadata).apply!
+ end
+ end
+
+ def apply_segment_ordinals
+ content_module.lessons.flat_map(&:segments).each_with_index do |segment, index|
+ segment.ordinal = index + 1
+ end
+
+ # ref is reset for each lesson
+ content_module.lessons.each do |lesson|
+ lesson.segments.each_with_index do |segment, index|
+ segment.ref ||= (index + 1).to_s.rjust(2, '0')
+ end
+ end
+ end
+
+ def load_authors
+ content_module.authors = Array.wrap(metadata[:authors]).map do |author|
+ Author.new(author)
+ end
+ end
+
+ def apply_additional_metadata
+ content_module.assign_attributes(simple_attributes)
+ end
+
+ private
+
+ def load_yaml_file(file)
+ Psych.load_file(file, permitted_classes: [Date]).deep_symbolize_keys
+ end
+ end
+end
diff --git a/app/lib/parser/lesson_metadata.rb b/app/lib/parser/lesson_metadata.rb
new file mode 100644
index 0000000..a24ddd5
--- /dev/null
+++ b/app/lib/parser/lesson_metadata.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Parser
+ # Parse a symbolised hash into a Part, with nested Episodes
+ class LessonMetadata
+ include SimpleAttributes
+
+ VALID_SIMPLE_ATTRIBUTES = %i[title description ordinal].freeze
+
+ attr_accessor :lesson, :metadata
+
+ def initialize(lesson, metadata)
+ @lesson = lesson
+ @metadata = metadata
+ end
+
+ def apply!
+ lesson.assign_attributes(simple_attributes)
+ end
+ end
+end
diff --git a/app/lib/parser/markdown_metadata.rb b/app/lib/parser/markdown_metadata.rb
index dc6bb3b..588b486 100644
--- a/app/lib/parser/markdown_metadata.rb
+++ b/app/lib/parser/markdown_metadata.rb
@@ -20,12 +20,16 @@ def metadata
def markdown_metadata
return {} if path.blank?
- @markdown_metadata ||= Psych.load(extract_metadata, symbolize_names: true, permitted_classes: [Date]).tap do |header_metadata|
+ @markdown_metadata ||= load_yaml(extract_metadata).tap do |header_metadata|
# If can't metadata, and also don't have a provided hash, then raise error
raise Parser::Error.new(file: path, msg: 'Unable to locate metadata at the top of the markdown') if header_metadata.blank? && @metadata.blank?
end || {}
rescue Psych::SyntaxError => e
raise Parser::Error.new(file: path, error: e)
end
+
+ def load_yaml(file)
+ Psych.load(file, symbolize_names: true, permitted_classes: [Date])
+ end
end
end
diff --git a/app/lib/parser/text_metadata.rb b/app/lib/parser/text_metadata.rb
new file mode 100644
index 0000000..5462a5f
--- /dev/null
+++ b/app/lib/parser/text_metadata.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Parser
+ # Parses the metadata at the top of a text markdown file
+ class TextMetadata
+ include MarkdownMetadata
+
+ VALID_SIMPLE_ATTRIBUTES = %i[title description short_description free ref authors_notes_md].freeze
+
+ attr_reader :text
+
+ def initialize(text)
+ @text = text
+ @path = text.markdown_file
+ end
+
+ def apply!
+ text.assign_attributes(simple_attributes)
+ text.cleanse_title!
+ text.authors += authors if authors.present?
+ end
+
+ def authors
+ @authors ||= metadata[:authors]&.map do |author|
+ Author.new(author)
+ end
+ end
+ end
+end
diff --git a/app/lib/parser/video_metadata.rb b/app/lib/parser/video_metadata.rb
index 929d24c..ec6fd08 100644
--- a/app/lib/parser/video_metadata.rb
+++ b/app/lib/parser/video_metadata.rb
@@ -29,7 +29,7 @@ def authors
# If we've read the captions path from the script file, it needs adjusting to be absolute
def check_captions_path
- return unless markdown_metadata&.include?(:captions_file)
+ return unless markdown_metadata&.include?(:captions_file) && markdown_metadata[:captions_file].present?
@markdown_metadata[:captions_file] = apply_path(@markdown_metadata[:captions_file])
end
diff --git a/app/lib/renderer/content_module.rb b/app/lib/renderer/content_module.rb
new file mode 100644
index 0000000..1ee3e2c
--- /dev/null
+++ b/app/lib/renderer/content_module.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Renderer
+ # Takes a sparse ContentModule object (i.e. parsed) and renders markdown / attaches images
+ class ContentModule
+ include ImageAttachable
+ include MarkdownRenderable
+ include Util::Logging
+
+ attr_accessor :disable_transcripts
+
+ def render
+ logger.info "Beginning module render: #{object.title}"
+ attach_images
+ render_markdown
+ object.lessons.each do |lesson|
+ lesson_renderer = Renderer::Lesson.new(lesson, image_provider:)
+ lesson_renderer.disable_transcripts = disable_transcripts
+ lesson_renderer.render
+ end
+ logger.info 'Completed module render'
+ end
+ end
+end
diff --git a/app/lib/renderer/lesson.rb b/app/lib/renderer/lesson.rb
new file mode 100644
index 0000000..c72fea6
--- /dev/null
+++ b/app/lib/renderer/lesson.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Renderer
+ # Takes a Lesson model, and updates it with markdown
+ class Lesson
+ include MarkdownRenderable
+ include ImageAttachable
+ include Util::Logging
+
+ attr_accessor :disable_transcripts
+
+ def render
+ logger.info "Beginning lesson render: #{object.title}"
+ attach_images
+ render_markdown
+ object.segments.each do |segment|
+ segment_renderer = Renderer::Segment.create(segment, image_provider:)
+ segment_renderer.disable_transcripts = disable_transcripts if segment_renderer.respond_to?(:disable_transcripts=)
+ segment_renderer.render
+ end
+ end
+ end
+end
diff --git a/app/lib/renderer/segment.rb b/app/lib/renderer/segment.rb
new file mode 100644
index 0000000..b66d304
--- /dev/null
+++ b/app/lib/renderer/segment.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Renderer
+ # Base renderer for episodes
+ class Segment
+ include MarkdownRenderable
+ include Util::Logging
+
+ def self.create(model, image_provider:)
+ case model
+ when ::Assessment
+ Renderer::Assessment.new(model, image_provider:)
+ when ::Video
+ Renderer::Video.new(model, image_provider:)
+ when ::Text
+ Renderer::Chapter.new(model, image_provider:)
+ else
+ raise "Unknown model type: #{model.class}"
+ end
+ end
+
+ def render
+ logger.info "Beginning episode render: #{object.ordinal}: #{object.title}"
+ render_markdown
+ end
+ end
+end
diff --git a/app/lib/runner/base.rb b/app/lib/runner/base.rb
index 36a757f..f6ccaed 100644
--- a/app/lib/runner/base.rb
+++ b/app/lib/runner/base.rb
@@ -123,10 +123,57 @@ def publish_pablo(source:, output:)
})
end
+ # module
+ def render_content_module(module_file:, local: false)
+ module_file ||= default_module_file
+
+ parser = Parser::Circulate.new(file: module_file)
+ content_module = parser.parse
+ image_extractor = ImageProvider::ContentModuleExtractor.new(content_module)
+ image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor)
+ image_provider&.process
+ renderer = Renderer::ContentModule.new(content_module, image_provider:)
+ renderer.render
+ content_module
+ end
+
+ def lint_content_module(module_file:, options: {})
+ module_file ||= default_module_file
+ logger.info("Attempting to lint using release file at #{module_file}")
+
+ CLI::UI::StdoutRouter.enable unless options['silent']
+
+ linter = Linting::ContentModuleLinter.new(file: module_file)
+ output = linter.lint(options:)
+ Cli::OutputFormatter.render(output) unless options['silent']
+ output
+ end
+
+ def circulate_content_module(module_file:)
+ module_file ||= default_module_file
+
+ parser = Parser::Circulate.new(file: module_file)
+ content_module = parser.parse
+
+ image_extractor = ImageProvider::ContentModuleExtractor.new(content_module)
+ image_provider = ImageProvider::Provider.new(extractor: image_extractor)
+ image_provider.process
+ Renderer::ContentModule.new(content_module, image_provider:).render
+ Api::Alexandria::ContentModuleUploader.upload(content_module)
+ notify_content_module_success(content_module:)
+ rescue StandardError => e
+ notify_content_module_failure(content_module: defined?(content_module) ? content_module : nil, details: e.full_message)
+ raise e
+ end
+
def default_publish_file
raise 'Override this in a subclass please'
end
+ def default_module_file
+ raise 'Override this in a subclass please'
+ end
+
def default_release_file
raise 'Override this in a subclass please'
end
diff --git a/app/lib/runner/interactive.rb b/app/lib/runner/interactive.rb
index ed6f316..50d9095 100644
--- a/app/lib/runner/interactive.rb
+++ b/app/lib/runner/interactive.rb
@@ -18,5 +18,9 @@ def default_pablo_source
def default_pablo_output
'/data/src/dist'
end
+
+ def default_module_file
+ '/data/src/module.yaml'
+ end
end
end
diff --git a/app/lib/snapshotter/slides.rb b/app/lib/snapshotter/slides.rb
index d695ddb..31d1e4c 100644
--- a/app/lib/snapshotter/slides.rb
+++ b/app/lib/snapshotter/slides.rb
@@ -5,10 +5,10 @@
module Snapshotter
# Creates snapshots of all the slides in a video course
class Slides
- attr_reader :video_course, :app_host, :app_port, :snapshot_host, :snapshot_port, :out_dir
+ attr_reader :app_host, :app_port, :snapshot_host, :snapshot_port, :out_dir
- def initialize(video_course:, app_host: 'app', app_port: 4567, snapshot_host: 'snapshot', snapshot_port: 3000, out_dir: '/data/src/slides') # rubocop:disable Metrics/ParameterLists
- @video_course = video_course
+ def initialize(data:, app_host: 'app', app_port: 4567, snapshot_host: 'snapshot', snapshot_port: 3000, out_dir: '/data/src/slides') # rubocop:disable Metrics/ParameterLists
+ @data = data
@app_host = app_host
@app_port = app_port
@snapshot_host = snapshot_host
@@ -18,7 +18,7 @@ def initialize(video_course:, app_host: 'app', app_port: 4567, snapshot_host: 's
def generate
browser = Ferrum::Browser.new(url: "http://#{snapshot_host}:#{snapshot_port}", window_size: [1920, 1080], timeout: 15)
- video_course.parts.flat_map(&:episodes).each do |episode|
+ data.each do |episode|
next unless episode.is_a?(Video)
browser.goto("#{app_base}/slides/#{episode.slug}")
diff --git a/app/lib/util/slack_notifiable.rb b/app/lib/util/slack_notifiable.rb
index 58fd6cd..e0c8b59 100644
--- a/app/lib/util/slack_notifiable.rb
+++ b/app/lib/util/slack_notifiable.rb
@@ -10,6 +10,7 @@ module SlackNotifiable # rubocop:disable Metrics/ModuleLength
FAILURE_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object_errors.png'
BOOK_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object_box-of-books.png'
VIDEO_COURSE_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object-box-videos.png'
+ CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object-box-content-module.png'
def notify_book_success(book:)
return unless notifiable?
@@ -35,6 +36,18 @@ def notify_video_course_failure(video_course:, details: nil)
notifier.post(blocks: video_course_failure_blocks(video_course:, details: details || 'N/A'))
end
+ def notify_content_module_success(content_module:)
+ return unless notifiable?
+
+ notifier.post(blocks: content_module_success_blocks(content_module:))
+ end
+
+ def notify_content_module_failure(content_module:, details: nil)
+ return unless notifiable?
+
+ notifier.post(blocks: content_module_failure_blocks(content_module:, details: details || 'N/A'))
+ end
+
def notifiable?
SLACK_WEBHOOK_URL.present?
end
@@ -109,6 +122,39 @@ def video_course_failure_blocks(video_course:, details:)
]
end
+ def content_module_success_blocks(content_module:)
+ [
+ intro_section(fields: standard_content_module_fields(content_module:),
+ message: ':white_check_mark: Content module upload successful!',
+ image_url: CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL,
+ alt_text: 'Upload successful'),
+ {
+ type: 'divider'
+ },
+ context(CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL)
+ ]
+ end
+
+ def content_module_failure_blocks(content_module:, details:)
+ [
+ intro_section(fields: standard_content_module_fields(content_module:),
+ message: ':x: Content module upload failed!',
+ image_url: FAILURE_IMAGE_URL,
+ alt_text: 'Upload failed'),
+ {
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: "```#{details}```"
+ }
+ },
+ {
+ type: 'divider'
+ },
+ context(CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL)
+ ]
+ end
+
def standard_book_fields(book:)
[
{
diff --git a/app/models/content_module.rb b/app/models/content_module.rb
new file mode 100644
index 0000000..d597036
--- /dev/null
+++ b/app/models/content_module.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# The top-level model object for a MMLP module
+class ContentModule
+ include ActiveModel::Model
+ include ActiveModel::Serializers::JSON
+ include Concerns::ImageAttachable
+ include Concerns::MarkdownRenderable
+
+ attr_accessor :shortcode, :version, :version_description, :title, :description_md,
+ :short_description, :released_at, :materials_url, :professional, :difficulty,
+ :platform, :language, :editor, :domains, :categories, :who_is_this_for_md,
+ :covered_concepts_md, :authors, :lessons, :git_commit_hash, :card_artwork_image,
+ :featured_banner_image, :twitter_card_image, :root_path, :access_personal,
+ :access_team
+
+ attr_markdown :who_is_this_for, source: :who_is_this_for_md, file: false
+ attr_markdown :covered_concepts, source: :covered_concepts_md, file: false
+ attr_markdown :description, source: :description_md, file: false
+ attr_image :card_artwork_image_url, source: :card_artwork_image, variants: %i[original w560 w240]
+ attr_image :featured_banner_image_url, source: :featured_banner_image, variants: %i[original w750 w225 w90]
+ attr_image :twitter_card_image_url, source: :twitter_card_image, variants: %i[original w1800]
+
+ validates :shortcode, :version, :title, :version_description, :description_md, :domains,
+ :categories, presence: true
+ validates_inclusion_of :difficulty, in: %w[beginner intermediate advanced]
+ validates_inclusion_of :professional, :access_personal, :access_team, in: [true, false]
+ validates :lessons, length: { minimum: 1 }, allow_blank: false, lessons: true
+ validates_each :domains do |record, attr, value|
+ value.each do |domain|
+ record.errors.add(attr, "(#{domain}) not included in the list") unless %w[ios android flutter server-side-swift unity macos professional-growth].include?(domain)
+ end
+ end
+
+ def initialize(attributes = {})
+ super
+ @lessons ||= []
+ end
+
+ # Used for serialisation
+ def attributes
+ { shortcode: nil, version: nil, version_description: nil, title: nil,
+ description: nil, short_description: nil, released_at: nil, materials_url: nil,
+ professional: nil, difficulty: nil, platform: nil, language: nil, editor: nil, domains: [],
+ categories: [], who_is_this_for: nil, covered_concepts: nil, authors: [], lessons: [],
+ git_commit_hash: nil, card_artwork_image_url: [], featured_banner_image_url: [],
+ twitter_card_image_url: [], access_personal: nil, access_team: nil }.stringify_keys
+ end
+
+ # Used for linting
+ def validation_name
+ title
+ end
+end
diff --git a/app/models/lesson.rb b/app/models/lesson.rb
new file mode 100644
index 0000000..2f2d0b5
--- /dev/null
+++ b/app/models/lesson.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# A MMLP module has multiple lesson
+class Lesson
+ include ActiveModel::Model
+ include ActiveModel::Serializers::JSON
+ include Concerns::ImageAttachable
+ include Concerns::MarkdownRenderable
+
+ attr_accessor :title, :description, :ordinal, :segments
+
+ validates :title, :ordinal, presence: true
+
+ def initialize(attributes = {})
+ super
+ @segments ||= []
+ end
+
+ # Used for serialisation
+ def attributes
+ { title: nil, description: nil, ordinal: nil, segments: [] }.stringify_keys
+ end
+
+ # Used for linting
+ def validation_name
+ title
+ end
+end
diff --git a/app/models/lessons_validator.rb b/app/models/lessons_validator.rb
new file mode 100644
index 0000000..f7ed239
--- /dev/null
+++ b/app/models/lessons_validator.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# A validator that will check an array of choices for uniqueness of ref
+class LessonsValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ return unless value.is_a?(Array)
+
+ check_correct_class(record, attribute, value)
+ check_unique_refs(record, attribute, value)
+ end
+
+ def check_correct_class(record, attribute, value)
+ value.each do |choice|
+ record.errors.add(attribute, "lesson #{choice} is not a Lesson") unless choice.is_a?(Lesson)
+ end
+ end
+
+ def check_unique_refs(record, attribute, value)
+ return unless value.is_a?(Array)
+
+ value.each do |lesson|
+ ref_counts = Hash.new(0)
+ lesson.segments.each { |segment| ref_counts[segment.ref] += 1 }
+ non_unique_refs = ref_counts.select { |_, count| count > 1 }.keys
+
+ non_unique_refs.each { |ref| record.errors.add(attribute, "segment ref #{ref} is not unique") }
+ end
+ end
+end
diff --git a/app/models/text.rb b/app/models/text.rb
new file mode 100644
index 0000000..e3c88ca
--- /dev/null
+++ b/app/models/text.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# The smallest quantity of content
+class Text
+ include ActiveModel::Model
+ include ActiveModel::Serializers::JSON
+ include Concerns::AutoNumberable
+ include Concerns::ImageAttachable
+ include Concerns::MarkdownRenderable
+ include Concerns::TitleCleanser
+
+ attr_accessor :title, :ordinal, :ref, :description, :authors, :markdown_file, :root_path, :free, :kind
+
+ attr_markdown :body, source: :markdown_file, file: true, wrapper_class: :wrapper_class
+ validates :title, :ordinal, :markdown_file, presence: true
+
+ def initialize(attributes = {})
+ super
+ @authors ||= []
+ @free ||= false
+ @kind ||= 'chapter'
+ end
+
+ def slug
+ "#{ref}-#{title.parameterize}"
+ end
+
+ # Used for serialisation
+ def attributes
+ { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false, episode_type: nil }.stringify_keys
+ end
+
+ # Used for linting
+ def validation_name
+ title
+ end
+
+ # For wrapping content
+ def wrapper_class
+ {
+ chapter: nil,
+ dedications: 'c-book-chapter__dedications',
+ 'team-bios': 'c-book-chapter__team'
+ }[kind&.to_sym]
+ end
+
+ def episode_type
+ 'text'
+ end
+end
diff --git a/app/models/video.rb b/app/models/video.rb
index c61f1a0..d4d1a5a 100644
--- a/app/models/video.rb
+++ b/app/models/video.rb
@@ -43,4 +43,9 @@ def attributes
def validation_name
title
end
+
+ # Content module videos are markdown files containing metadata
+ def markdown_file
+ script_file
+ end
end
diff --git a/app/server/robles_content_module_server.rb b/app/server/robles_content_module_server.rb
new file mode 100644
index 0000000..1fca1dd
--- /dev/null
+++ b/app/server/robles_content_module_server.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+require 'rack-livereload'
+
+# A local preview server for robles
+class RoblesContentModuleServer < Sinatra::Application
+ set :bind, '0.0.0.0'
+ set :views, "#{__dir__}/views"
+ set :public_folder, "#{__dir__}/public"
+ set :static_cache_control, [max_age: 0]
+
+ use Rack::LiveReload, host: 'localhost', source: :vendored
+
+ helpers do
+ def slide_path(segment)
+ "/slides/#{segment.slug}"
+ end
+
+ def transcript_path(segment)
+ "/transcripts/#{segment.slug}"
+ end
+
+ def assessment_path(segment)
+ "/assessments/#{segment.slug}"
+ end
+
+ def text_path(segment)
+ "/texts/#{segment.slug}"
+ end
+
+ def class_for_domain(course)
+ if course.domains.count > 1
+ 'multi-domain'
+ else
+ course.domains.first
+ end
+ end
+
+ # scss is no longer a built-in helper in sinatra
+ # However, we can almost just proxy it to Tilt
+ def scss(template, options = {}, locals = {})
+ options.merge!(layout: false, exclude_outvar: true)
+ # Set the content type to css
+ render(:scss, template, options, locals).dup.tap do |css|
+ css.extend(ContentTyped).content_type = :css
+ end
+ end
+ end
+
+ before do
+ cache_control max_age: 0
+ end
+
+ get '/' do
+ @content_module = content_module(with_transcript: false)
+ erb :'content_modules/index.html', locals: { content_module: @content_module, title: "robles Preview: #{@content_module.title}" }, layout: :'content_modules/layout.html'
+ end
+
+ get '/slides/:slug' do
+ @content_module = content_module(with_transcript: false)
+ segment = segment_for_slug(params[:slug])
+ raise Sinatra::NotFound unless segment.present?
+
+ lesson = @content_module.lessons.find { |p| p.segments.include?(segment) }
+
+ erb :'content_modules/segment_slide.html',
+ locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" },
+ layout: :'content_modules/layout.html'
+ end
+
+ get '/transcripts/:slug' do
+ @content_module = content_module(with_transcript: true)
+ segment = segment_for_slug(params[:slug])
+ raise Sinatra::NotFound unless segment.present?
+
+ lesson = @content_module.lessons.find { |p| p.segments.include?(segment) }
+
+ erb :'content_modules/segment_transcript.html',
+ locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" },
+ layout: :'content_modules/layout.html'
+ end
+
+ get '/assessments/:slug' do
+ @content_module = content_module(with_transcript: false)
+ segment = segment_for_slug(params[:slug])
+ raise Sinatra::NotFound unless segment.present?
+
+ lesson = @content_module.lessons.find { |p| p.segments.include?(segment) }
+
+ erb :'content_modules/assessment.html',
+ locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" },
+ layout: :'content_modules/layout.html'
+ end
+
+ get '/texts/:slug' do
+ @content_module = content_module(with_transcript: false)
+ segment = segment_for_slug(params[:slug])
+ raise Sinatra::NotFound unless segment.present?
+
+ lesson = @content_module.lessons.find { |p| p.segments.include?(segment) }
+
+ erb :'content_modules/text.html',
+ locals: {
+ segment:,
+ content_module: @content_module,
+ title: "robles Preview: #{segment.title}",
+ word_counter: word_counter_for_segment(segment)
+ },
+ layout: :'content_modules/layout.html'
+ end
+
+ get '/assets/*' do
+ local_url = File.join('/data/src/', params[:splat])
+ raise Sinatra::NotFound unless acceptable_image_extension(File.extname(local_url)) && File.exist?(local_url)
+
+ send_file(local_url)
+ end
+
+ get '/styles.css' do
+ scss :'styles/application', style: :expanded
+ end
+
+ def content_module(with_transcript: true)
+ parser = Parser::Circulate.new(file: module_file)
+ content_module = parser.parse
+ renderer = Renderer::ContentModule.new(content_module, image_provider: nil)
+ renderer.disable_transcripts = !with_transcript
+ renderer.render
+ content_module.image_attachment_loop { |local_url| servable_image_url(local_url) }
+ content_module
+ end
+
+ def render_string(content)
+ Renderer::MarkdownStringRenderer.new(content:).render
+ end
+
+ def word_counter_for_segment(segment)
+ markdown = File.read(segment.markdown_file)
+ Linting::Markdown::WordCounter.new(markdown)
+ end
+
+ def segment_for_slug(slug)
+ @content_module.lessons.flat_map(&:segments).find { |segment| segment.slug == slug }
+ end
+
+ def module_file
+ '/data/src/module.yaml'
+ end
+
+ def servable_image_url(local_url)
+ [OpenStruct.new(url: local_url&.gsub(%r{/data/src}, '/assets'), variant: :original)] # rubocop:disable Style/OpenStructUse
+ end
+
+ def acceptable_image_extension(extension)
+ %w[jpg jpeg png gif].include?(extension.sub('.', '').downcase)
+ end
+end
diff --git a/app/server/views/content_modules/_index_table_of_contents.html.erb b/app/server/views/content_modules/_index_table_of_contents.html.erb
new file mode 100644
index 0000000..ee20012
--- /dev/null
+++ b/app/server/views/content_modules/_index_table_of_contents.html.erb
@@ -0,0 +1,31 @@
+
+ <% content_module.lessons.each do |lesson| %>
+
Lesson <%= lesson.ordinal %>: <%= lesson.title %>
+
+
+ <%= lesson.description %>
+
+
+
+
+ <% lesson.segments.each do |segment| %>
+
+
+ <% if segment.is_a?(Video) %>
+
+ <% if segment.free %>
Free <% end %>
+
slide
+ <% elsif segment.is_a?(Assessment) %>
+
+ <% elsif segment.is_a?(Text) %>
+
+ <% end %>
+
+
<%= segment.description %>
+
+ <%= segment.ref %>
+
+
+ <% end %>
+ <% end %>
+
diff --git a/app/server/views/content_modules/_table_of_contents.html.erb b/app/server/views/content_modules/_table_of_contents.html.erb
new file mode 100644
index 0000000..2c1708d
--- /dev/null
+++ b/app/server/views/content_modules/_table_of_contents.html.erb
@@ -0,0 +1,41 @@
+
diff --git a/app/server/views/content_modules/assessment.html.erb b/app/server/views/content_modules/assessment.html.erb
new file mode 100644
index 0000000..2afdcd0
--- /dev/null
+++ b/app/server/views/content_modules/assessment.html.erb
@@ -0,0 +1,75 @@
+
+
+
diff --git a/app/server/views/content_modules/index.html.erb b/app/server/views/content_modules/index.html.erb
new file mode 100644
index 0000000..c9f7365
--- /dev/null
+++ b/app/server/views/content_modules/index.html.erb
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= content_module.description %>
+
+
+ <%= erb :'content_modules/_index_table_of_contents.html', locals: { content_module: content_module } %>
+
+
+
+
Who is this course for
+
+ <%= content_module.who_is_this_for %>
+
+
+
+
Concepts covered in this course
+
+ <%= content_module.covered_concepts %>
+
+
+
+
+
+
+
+
+
+
+ <% if content_module.professional %>
+
+ Pro
+
+ <% end %>
+
+
+
<%= content_module.title %>
+
+
+
+
+
+
+
diff --git a/app/server/views/content_modules/layout.html.erb b/app/server/views/content_modules/layout.html.erb
new file mode 100644
index 0000000..30fc796
--- /dev/null
+++ b/app/server/views/content_modules/layout.html.erb
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ <%= title || 'robles Preview' %>
+
+
+
+
+
+
+
+
+ <%= erb :'shared/_svg_icons.html' %>
+
+
+
+ <%= yield %>
+
+
+
diff --git a/app/server/views/content_modules/segment_slide.html.erb b/app/server/views/content_modules/segment_slide.html.erb
new file mode 100644
index 0000000..703dd23
--- /dev/null
+++ b/app/server/views/content_modules/segment_slide.html.erb
@@ -0,0 +1,24 @@
+
+
+ <%= content_module.title %>
+
+
+ <% if content_module.lessons.count> 1 %>
+
Lesson <%= lesson.ordinal %>: <%= lesson.title %>
+
+ <% end %>
+
+ <%=segment.ref%>. <%= segment.title %>
+
+
+
+
+
+
+
+ <% if content_module.featured_banner_image_url&.first&.url.present? %>
+
+
+
+ <% end %>
+
diff --git a/app/server/views/content_modules/segment_transcript.html.erb b/app/server/views/content_modules/segment_transcript.html.erb
new file mode 100644
index 0000000..e5e29a8
--- /dev/null
+++ b/app/server/views/content_modules/segment_transcript.html.erb
@@ -0,0 +1,67 @@
+
+
+
diff --git a/app/server/views/content_modules/text.html.erb b/app/server/views/content_modules/text.html.erb
new file mode 100644
index 0000000..96dc17b
--- /dev/null
+++ b/app/server/views/content_modules/text.html.erb
@@ -0,0 +1,85 @@
+
+ Word count: <%= "#{word_counter.count} / #{word_counter.word_limit}" %>.
+ <%= "This chapter exceeds the word limit." if word_counter.exceeds_word_limit? %>
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 549ec93..eddded9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,13 +7,15 @@ services:
# When working on videos
#- ../videos/video-aiar:/data/src
# When working on books
- - ../../books/alg:/data/src
+ #- ../../books/alg:/data/src
# When working on pablo
#- ../../sites/pablo:/data/src
- command: bin/robles video serve
+ # When working on m3
+ - ../../m3/m3-devtest:/data/src
+ command: bin/robles module serve
env_file: .env
environment:
- - IMAGES_CDN_HOST=assets.robles.raywenderlich.com
+ - IMAGES_CDN_HOST=assets.robles.kodeco.com
ports:
- "4567:4567"
- "35729:35729"