Compare commits

...

3 Commits

Author SHA1 Message Date
ea61c91774 Removing id:discourse.resolver-resolutions notice. 2026-03-23 16:22:23 -04:00
215a438e56 Renaming plugin. 2026-03-20 12:00:07 -04:00
da6357fcfb Renaming plugin 2026-03-20 12:00:06 -04:00
9 changed files with 270 additions and 263 deletions

View File

@@ -1,4 +1,4 @@
# discourse-url-to-article # discourse-bookmark-url
A Discourse plugin that detects when a URL is pasted into the **topic title** field and offers to scrape the page, extracting the article content (à la browser Reader Mode) and populating the **composer body** with a clean Markdown rendering. A Discourse plugin that detects when a URL is pasted into the **topic title** field and offers to scrape the page, extracting the article content (à la browser Reader Mode) and populating the **composer body** with a clean Markdown rendering.
@@ -25,7 +25,7 @@ hooks:
- exec: - exec:
cd: $home/plugins cd: $home/plugins
cmd: cmd:
- git clone https://code.draft13.com/robert/discourse-url-to-article.git - git clone https://code.draft13.com/robert/discourse-bookmark-url.git
``` ```
Then rebuild: `./launcher rebuild app` Then rebuild: `./launcher rebuild app`
@@ -36,12 +36,12 @@ Then rebuild: `./launcher rebuild app`
| Setting | Default | Description | | Setting | Default | Description |
|---|---|---| |---|---|---|
| `url_to_article_enabled` | `true` | Enable/disable the plugin | | `bookmark_url_enabled` | `true` | Enable/disable the plugin |
| `url_to_article_auto_populate` | `false` | Populate body automatically without button click | | `bookmark_url_auto_populate` | `false` | Populate body automatically without button click |
| `url_to_article_max_content_length` | `50000` | Max chars extracted from a page | | `bookmark_url_max_content_length` | `50000` | Max chars extracted from a page |
| `url_to_article_fetch_timeout` | `10` | Seconds before HTTP fetch times out | | `bookmark_url_fetch_timeout` | `10` | Seconds before HTTP fetch times out |
| `url_to_article_allowed_domains` | *(blank = all)* | Comma-separated domain allowlist | | `bookmark_url_allowed_domains` | *(blank = all)* | Comma-separated domain allowlist |
| `url_to_article_blocked_domains` | `localhost,127.0.0.1,…` | SSRF blocklist | | `bookmark_url_blocked_domains` | `localhost,127.0.0.1,…` | SSRF blocklist |
--- ---
@@ -49,15 +49,15 @@ Then rebuild: `./launcher rebuild app`
### Frontend (Ember.js) ### Frontend (Ember.js)
`initializers/url-to-article.js` hooks into the `composer-editor` component and observes the `composer.model.title` property via Ember's observer system. When the title matches a bare URL pattern: `initializers/bookmark-url.js` hooks into the `composer-editor` component and observes the `composer.model.title` property via Ember's observer system. When the title matches a bare URL pattern:
1. A dismissible bar appears above the editor offering to import the article. 1. A dismissible bar appears above the editor offering to import the article.
2. On click (or automatically if `auto_populate` is on), it POSTs to `/url-to-article/extract`. 2. On click (or automatically if `auto_populate` is on), it POSTs to `/bookmark-url/extract`.
3. The response populates `composer.model.reply` (body) and optionally updates the title. 3. The response populates `composer.model.reply` (body) and optionally updates the title.
### Backend (Ruby) ### Backend (Ruby)
`ArticleExtractor` in `lib/url_to_article/article_extractor.rb`: `ArticleExtractor` in `lib/bookmark_url/article_extractor.rb`:
1. **Fetches** the HTML via `Net::HTTP` with a browser-like User-Agent (follows one redirect). 1. **Fetches** the HTML via `Net::HTTP` with a browser-like User-Agent (follows one redirect).
2. **Extracts metadata** from Open Graph / Twitter Card / standard `<meta>` tags. 2. **Extracts metadata** from Open Graph / Twitter Card / standard `<meta>` tags.
@@ -67,7 +67,7 @@ Then rebuild: `./launcher rebuild app`
### Security ### Security
- Only authenticated users can call `/url-to-article/extract`. - Only authenticated users can call `/bookmark-url/extract`.
- Only `http`/`https` schemes are allowed. - Only `http`/`https` schemes are allowed.
- Configurable domain blocklist (loopback/private addresses blocked by default). - Configurable domain blocklist (loopback/private addresses blocked by default).
- Optional allowlist to restrict to specific domains. - Optional allowlist to restrict to specific domains.
@@ -97,7 +97,7 @@ Full article text in Markdown...
### Custom extraction logic ### Custom extraction logic
Subclass or monkey-patch `UrlToArticle::ArticleExtractor` in a separate plugin to add site-specific selectors or post-processing. Subclass or monkey-patch `BookmarkUrl::ArticleExtractor` in a separate plugin to add site-specific selectors or post-processing.
### Paywall / JS-rendered sites ### Paywall / JS-rendered sites

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module UrlToArticle module BookmarkUrl
class ArticlesController < ::ApplicationController class ArticlesController < ::ApplicationController
requires_login requires_login
before_action :ensure_enabled! before_action :ensure_enabled!
@@ -18,14 +18,14 @@ module UrlToArticle
url: result.url, url: result.url,
} }
rescue => e rescue => e
Rails.logger.warn("[url-to-article] Extraction failed for #{@url}: #{e.message}") Rails.logger.warn("[bookmark-url] Extraction failed for #{@url}: #{e.message}")
render json: { error: "Could not extract article: #{e.message}" }, status: :unprocessable_entity render json: { error: "Could not extract article: #{e.message}" }, status: :unprocessable_entity
end end
private private
def ensure_enabled! def ensure_enabled!
raise Discourse::NotFound unless SiteSetting.url_to_article_enabled raise Discourse::NotFound unless SiteSetting.bookmark_url_enabled
end end
def validate_url! def validate_url!
@@ -42,7 +42,7 @@ module UrlToArticle
end end
# SSRF protection — block private/loopback addresses # SSRF protection — block private/loopback addresses
blocked_domains = SiteSetting.url_to_article_blocked_domains blocked_domains = SiteSetting.bookmark_url_blocked_domains
.split(",").map(&:strip).reject(&:empty?) .split(",").map(&:strip).reject(&:empty?)
if blocked_domains.any? { |d| uri.host&.include?(d) } if blocked_domains.any? { |d| uri.host&.include?(d) }
@@ -50,7 +50,7 @@ module UrlToArticle
end end
# Optionally enforce an allowlist # Optionally enforce an allowlist
allowed_domains = SiteSetting.url_to_article_allowed_domains allowed_domains = SiteSetting.bookmark_url_allowed_domains
.split(",").map(&:strip).reject(&:empty?) .split(",").map(&:strip).reject(&:empty?)
if allowed_domains.any? && !allowed_domains.any? { |d| uri.host&.end_with?(d) } if allowed_domains.any? && !allowed_domains.any? { |d| uri.host&.end_with?(d) }

View File

@@ -0,0 +1,220 @@
import { apiInitializer } from "discourse/lib/api";
import { ajax } from "discourse/lib/ajax";
const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i;
const STRINGS = {
bar_label: "URL detected — import as article?",
fetch_button: "Import Article",
onebox_button: "Use Onebox",
dismiss: "Dismiss",
fetching: "Fetching…",
success: "Article imported!",
error_generic: "Unknown error",
error_prefix: "Error:",
source_label: "Source",
retry_button: "Retry",
};
export default apiInitializer("1.8.0", (api) => {
if (!api.container.lookup("service:site-settings").bookmark_url_enabled) {
return;
}
let titleInputEl = null;
let titlePasteHandler = null;
let pendingUrl = null;
// ---- Bar UI -------------------------------------------------------
function showArticleBar(url) {
hideArticleBar();
const bar = document.createElement("div");
bar.className = "bookmark-url-bar";
bar.innerHTML = `
<span class="bookmark-url-icon">📄</span>
<span class="bookmark-url-label">${STRINGS.bar_label}</span>
<button class="btn btn-small btn-primary bookmark-url-btn">
${STRINGS.fetch_button}
</button>
<button class="btn btn-small btn-default bookmark-url-onebox-btn">
${STRINGS.onebox_button}
</button>
<button class="btn btn-small btn-flat bookmark-url-dismiss"
aria-label="${STRINGS.dismiss}">✕</button>
`;
bar.querySelector(".bookmark-url-btn").addEventListener("click", () => {
fetchAndPopulate(url);
});
bar.querySelector(".bookmark-url-onebox-btn").addEventListener("click", () => {
hideArticleBar();
commitUrlToModel();
});
bar.querySelector(".bookmark-url-dismiss").addEventListener("click", () => {
hideArticleBar();
pendingUrl = null;
});
const container = document.querySelector(".d-editor-container");
if (container) {
container.parentElement.insertBefore(bar, container);
} else {
document
.querySelector(".composer-fields")
?.insertAdjacentElement("afterbegin", bar);
}
}
function hideArticleBar() {
document
.querySelectorAll(".bookmark-url-bar")
.forEach((el) => el.remove());
}
function setStatus(message, type = "info") {
const bar = document.querySelector(".bookmark-url-bar");
if (!bar) return;
let status = bar.querySelector(".bookmark-url-status");
if (!status) {
status = document.createElement("span");
status.className = "bookmark-url-status";
bar.appendChild(status);
}
status.textContent = message;
status.className = `bookmark-url-status bookmark-url-status--${type}`;
}
// Release the URL into Ember's data-binding so Discourse handles it normally
function commitUrlToModel() {
const input = titleInputEl;
const url = pendingUrl;
if (!input || !url) return;
input.value = url;
input.dispatchEvent(new Event("input", { bubbles: true }));
pendingUrl = null;
}
// ---- Fetch & populate ---------------------------------------------
async function fetchAndPopulate(url) {
const bar = document.querySelector(".bookmark-url-bar");
const btn = bar?.querySelector(".bookmark-url-btn");
if (btn) {
btn.disabled = true;
btn.textContent = STRINGS.fetching;
}
setStatus(STRINGS.fetching, "info");
try {
const data = await ajax("/bookmark-url/extract", {
type: "POST",
data: { url },
});
if (data.error) throw new Error(data.error);
populateComposer(data);
setStatus(STRINGS.success, "success");
setTimeout(() => hideArticleBar(), 3000);
} catch (err) {
const msg =
err.jqXHR?.responseJSON?.error ||
err.message ||
STRINGS.error_generic;
setStatus(`${STRINGS.error_prefix} ${msg}`, "error");
if (btn) {
btn.disabled = false;
btn.textContent = STRINGS.retry_button;
}
}
}
function populateComposer(data) {
const composerService = api.container.lookup("service:composer");
const composerModel = composerService?.model;
if (!composerModel) return;
const lines = [];
const siteName = data.site_name ? `**${data.site_name}**` : "";
const byline = data.byline ? ` — *${data.byline}*` : "";
if (siteName || byline) {
lines.push(`> ${siteName}${byline}`);
lines.push(`> ${STRINGS.source_label}: <${data.url}>`);
lines.push("");
} else {
lines.push(`> ${STRINGS.source_label}: <${data.url}>`);
lines.push("");
}
if (data.description) {
lines.push(`*${data.description}*`);
lines.push("");
lines.push("---");
lines.push("");
}
lines.push(data.markdown || "");
composerModel.set("title", data.title || pendingUrl || "");
composerModel.set("reply", lines.join("\n"));
pendingUrl = null;
if (titleInputEl) {
titleInputEl.value = composerModel.get("title");
}
}
// ---- Title paste interception -------------------------------------
// capture:true so our handler runs before Discourse's, then
// preventDefault + stopImmediatePropagation so Discourse never sees
// the paste event — no title lookup, no onebox race.
function attachTitlePasteListener(attempts = 0) {
const input = document.querySelector("#reply-title");
if (input) {
titleInputEl = input;
titlePasteHandler = (e) => {
const text = (e.clipboardData || window.clipboardData)
.getData("text/plain")
.trim();
if (!URL_REGEX.test(text)) return;
e.preventDefault();
e.stopImmediatePropagation();
// Show URL in the field visually without going through Ember binding
input.value = text;
pendingUrl = text;
showArticleBar(text);
};
input.addEventListener("paste", titlePasteHandler, true);
} else if (attempts < 15) {
setTimeout(() => attachTitlePasteListener(attempts + 1), 200);
}
}
function detachTitlePasteListener() {
if (titleInputEl && titlePasteHandler) {
titleInputEl.removeEventListener("paste", titlePasteHandler, true);
}
titleInputEl = null;
titlePasteHandler = null;
pendingUrl = null;
hideArticleBar();
}
// ---- Composer lifecycle -------------------------------------------
api.onAppEvent("composer:opened", () => {
attachTitlePasteListener();
});
api.onAppEvent("composer:closed", () => {
detachTitlePasteListener();
});
});

View File

@@ -1,213 +0,0 @@
import { apiInitializer } from "discourse/lib/api";
import { ajax } from "discourse/lib/ajax";
const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i;
const STRINGS = {
bar_label: "URL detected — import as article?",
fetch_button: "Import Article",
onebox_button: "Use Onebox",
dismiss: "Dismiss",
fetching: "Fetching…",
success: "Article imported!",
error_generic: "Unknown error",
error_prefix: "Error:",
source_label: "Source",
retry_button: "Retry",
};
export default apiInitializer("1.8.0", (api) => {
if (!api.container.lookup("site-settings:main").url_to_article_enabled) {
return;
}
api.modifyClass("component:composer-editor", {
pluginId: "url-to-article",
didInsertElement() {
this._super(...arguments);
this._attachTitlePasteListener();
},
willDestroyElement() {
this._super(...arguments);
if (this._titleInputEl && this._titlePasteHandler) {
this._titleInputEl.removeEventListener(
"paste",
this._titlePasteHandler,
true
);
}
},
// ---- Title paste interception -------------------------------------
// capture:true so our handler runs before Discourse's, then
// preventDefault + stopImmediatePropagation so Discourse never sees
// the paste event — no title lookup, no onebox race.
_attachTitlePasteListener(attempts = 0) {
const input = document.querySelector("#reply-title");
if (input) {
this._titleInputEl = input;
this._titlePasteHandler = (e) => {
const text = (e.clipboardData || window.clipboardData)
.getData("text/plain")
.trim();
if (!URL_REGEX.test(text)) return;
e.preventDefault();
e.stopImmediatePropagation();
// Show URL in the field visually without going through Ember binding
input.value = text;
this._pendingUrl = text;
this._showArticleBar(text);
};
input.addEventListener("paste", this._titlePasteHandler, true);
} else if (attempts < 15) {
setTimeout(() => this._attachTitlePasteListener(attempts + 1), 200);
}
},
// ---- Bar UI -------------------------------------------------------
_showArticleBar(url) {
this._hideArticleBar();
const bar = document.createElement("div");
bar.className = "url-to-article-bar";
bar.innerHTML = `
<span class="url-to-article-icon">📄</span>
<span class="url-to-article-label">${STRINGS.bar_label}</span>
<button class="btn btn-small btn-primary url-to-article-btn">
${STRINGS.fetch_button}
</button>
<button class="btn btn-small btn-default url-to-article-onebox-btn">
${STRINGS.onebox_button}
</button>
<button class="btn btn-small btn-flat url-to-article-dismiss"
aria-label="${STRINGS.dismiss}">✕</button>
`;
bar.querySelector(".url-to-article-btn").addEventListener("click", () => {
this._fetchAndPopulate(url);
});
bar.querySelector(".url-to-article-onebox-btn").addEventListener("click", () => {
this._hideArticleBar();
this._commitUrlToModel();
});
bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => {
this._hideArticleBar();
this._pendingUrl = null;
});
const container = this.element.querySelector(".d-editor-container");
if (container) {
container.parentElement.insertBefore(bar, container);
} else {
this.element.insertAdjacentElement("afterbegin", bar);
}
},
_hideArticleBar() {
this.element
?.querySelectorAll(".url-to-article-bar")
.forEach((el) => el.remove());
},
_setStatus(message, type = "info") {
const bar = this.element?.querySelector(".url-to-article-bar");
if (!bar) return;
let status = bar.querySelector(".url-to-article-status");
if (!status) {
status = document.createElement("span");
status.className = "url-to-article-status";
bar.appendChild(status);
}
status.textContent = message;
status.className = `url-to-article-status url-to-article-status--${type}`;
},
// Release the URL into Ember's data-binding so Discourse handles it normally
_commitUrlToModel() {
const input = this._titleInputEl;
const url = this._pendingUrl;
if (!input || !url) return;
input.value = url;
input.dispatchEvent(new Event("input", { bubbles: true }));
this._pendingUrl = null;
},
// ---- Fetch & populate ---------------------------------------------
async _fetchAndPopulate(url) {
const bar = this.element?.querySelector(".url-to-article-bar");
const btn = bar?.querySelector(".url-to-article-btn");
if (btn) {
btn.disabled = true;
btn.textContent = STRINGS.fetching;
}
this._setStatus(STRINGS.fetching, "info");
try {
const data = await ajax("/url-to-article/extract", {
type: "POST",
data: { url },
});
if (data.error) throw new Error(data.error);
this._populateComposer(data);
this._setStatus(STRINGS.success, "success");
setTimeout(() => this._hideArticleBar(), 3000);
} catch (err) {
const msg =
err.jqXHR?.responseJSON?.error ||
err.message ||
STRINGS.error_generic;
this._setStatus(`${STRINGS.error_prefix} ${msg}`, "error");
if (btn) {
btn.disabled = false;
btn.textContent = STRINGS.retry_button;
}
}
},
_populateComposer(data) {
const composerModel = this.get("composer.model");
if (!composerModel) return;
const lines = [];
const siteName = data.site_name ? `**${data.site_name}**` : "";
const byline = data.byline ? ` — *${data.byline}*` : "";
if (siteName || byline) {
lines.push(`> ${siteName}${byline}`);
lines.push(`> ${STRINGS.source_label}: <${data.url}>`);
lines.push("");
} else {
lines.push(`> ${STRINGS.source_label}: <${data.url}>`);
lines.push("");
}
if (data.description) {
lines.push(`*${data.description}*`);
lines.push("");
lines.push("---");
lines.push("");
}
lines.push(data.markdown || "");
composerModel.set("title", data.title || this._pendingUrl || "");
composerModel.set("reply", lines.join("\n"));
this._pendingUrl = null;
if (this._titleInputEl) {
this._titleInputEl.value = composerModel.get("title");
}
},
});
});

View File

@@ -1,6 +1,6 @@
/* URL-to-Article plugin styles */ /* Bookmark URL plugin styles */
.url-to-article-bar { .bookmark-url-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -18,39 +18,39 @@
order: -1; order: -1;
} }
.url-to-article-icon { .bookmark-url-icon {
font-size: 1.1em; font-size: 1.1em;
flex-shrink: 0; flex-shrink: 0;
} }
.url-to-article-label { .bookmark-url-label {
flex: 1; flex: 1;
min-width: 8rem; min-width: 8rem;
color: var(--primary-medium); color: var(--primary-medium);
font-weight: 500; font-weight: 500;
} }
.url-to-article-btn { .bookmark-url-btn {
flex-shrink: 0; flex-shrink: 0;
} }
.url-to-article-dismiss { .bookmark-url-dismiss {
flex-shrink: 0; flex-shrink: 0;
padding: 0.25rem 0.5rem !important; padding: 0.25rem 0.5rem !important;
color: var(--primary-medium) !important; color: var(--primary-medium) !important;
} }
.url-to-article-status { .bookmark-url-status {
font-style: italic; font-style: italic;
font-size: var(--font-down-1); font-size: var(--font-down-1);
&.url-to-article-status--info { &.bookmark-url-status--info {
color: var(--tertiary); color: var(--tertiary);
} }
&.url-to-article-status--success { &.bookmark-url-status--success {
color: var(--success); color: var(--success);
} }
&.url-to-article-status--error { &.bookmark-url-status--error {
color: var(--danger); color: var(--danger);
} }
} }

View File

@@ -1,5 +1,5 @@
en: en:
url_to_article: bookmark_url:
bar_label: "URL detected — import as article?" bar_label: "URL detected — import as article?"
fetch_button: "Import Article" fetch_button: "Import Article"
onebox_button: "Use Onebox" onebox_button: "Use Onebox"

View File

@@ -1,31 +1,31 @@
plugins: plugins:
url_to_article_enabled: bookmark_url_enabled:
default: true default: true
client: true client: true
type: bool type: bool
url_to_article_auto_populate: bookmark_url_auto_populate:
default: false default: false
client: true client: true
type: bool type: bool
description: "Automatically populate the body when a URL is detected in the title (no button click needed)" description: "Automatically populate the body when a URL is detected in the title (no button click needed)"
url_to_article_max_content_length: bookmark_url_max_content_length:
default: 50000 default: 50000
type: integer type: integer
description: "Maximum number of characters to extract from a page" description: "Maximum number of characters to extract from a page"
url_to_article_fetch_timeout: bookmark_url_fetch_timeout:
default: 10 default: 10
type: integer type: integer
description: "Seconds to wait when fetching a URL" description: "Seconds to wait when fetching a URL"
url_to_article_allowed_domains: bookmark_url_allowed_domains:
default: "" default: ""
type: string type: string
description: "Comma-separated list of allowed domains. Leave blank to allow all." description: "Comma-separated list of allowed domains. Leave blank to allow all."
url_to_article_blocked_domains: bookmark_url_blocked_domains:
default: "localhost,127.0.0.1,0.0.0.0,::1" default: "localhost,127.0.0.1,0.0.0.0,::1"
type: string type: string
description: "Comma-separated list of blocked domains (SSRF protection)" description: "Comma-separated list of blocked domains (SSRF protection)"

View File

@@ -7,7 +7,7 @@ require "net/http"
require "uri" require "uri"
require "timeout" require "timeout"
module UrlToArticle module BookmarkUrl
class ArticleExtractor class ArticleExtractor
NOISE_SELECTORS = %w[ NOISE_SELECTORS = %w[
script style noscript iframe nav footer header script style noscript iframe nav footer header
@@ -80,7 +80,7 @@ module UrlToArticle
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def fetch_html def fetch_html
Timeout.timeout(SiteSetting.url_to_article_fetch_timeout) do Timeout.timeout(SiteSetting.bookmark_url_fetch_timeout) do
response = do_get(@uri) response = do_get(@uri)
if response.is_a?(Net::HTTPRedirection) && response["location"] if response.is_a?(Net::HTTPRedirection) && response["location"]
@@ -100,10 +100,10 @@ module UrlToArticle
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https" http.use_ssl = uri.scheme == "https"
http.open_timeout = 5 http.open_timeout = 5
http.read_timeout = SiteSetting.url_to_article_fetch_timeout http.read_timeout = SiteSetting.bookmark_url_fetch_timeout
req = Net::HTTP::Get.new(uri.request_uri) req = Net::HTTP::Get.new(uri.request_uri)
req["User-Agent"] = "Mozilla/5.0 (compatible; Discourse/url-to-article)" req["User-Agent"] = "Mozilla/5.0 (compatible; Discourse/bookmark-url)"
req["Accept"] = "text/html,application/xhtml+xml" req["Accept"] = "text/html,application/xhtml+xml"
req["Accept-Language"] = "en-US,en;q=0.9" req["Accept-Language"] = "en-US,en;q=0.9"
@@ -335,7 +335,7 @@ module UrlToArticle
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def truncate(text) def truncate(text)
max = SiteSetting.url_to_article_max_content_length max = SiteSetting.bookmark_url_max_content_length
return text if text.length <= max return text if text.length <= max
text[0...max] + "\n\n*[Content truncated — visit the original article for the full text.]*" text[0...max] + "\n\n*[Content truncated — visit the original article for the full text.]*"
end end

View File

@@ -1,32 +1,32 @@
# frozen_string_literal: true # frozen_string_literal: true
# name: discourse-url-to-article # name: discourse-bookmark-url
# about: Scrapes a URL pasted into the topic title and populates the composer body with the article content # about: Scrapes a URL pasted into the topic title and populates the composer body with the article content
# version: 0.1.0 # version: 0.1.0
# authors: Robert Johnson # authors: Robert Johnson
# url: https://code.draft13.com/robert/discourse-url-to-article # url: https://code.draft13.com/robert/discourse-bookmark-url
enabled_site_setting :url_to_article_enabled enabled_site_setting :bookmark_url_enabled
after_initialize do after_initialize do
require_relative "lib/url_to_article/article_extractor" require_relative "lib/bookmark_url/article_extractor"
module ::UrlToArticle module ::BookmarkUrl
PLUGIN_NAME = "discourse-url-to-article" PLUGIN_NAME = "discourse-bookmark-url"
class Engine < ::Rails::Engine class Engine < ::Rails::Engine
engine_name PLUGIN_NAME engine_name PLUGIN_NAME
isolate_namespace UrlToArticle isolate_namespace BookmarkUrl
end end
end end
require_relative "app/controllers/url_to_article/articles_controller" require_relative "app/controllers/bookmark_url/articles_controller"
UrlToArticle::Engine.routes.draw do BookmarkUrl::Engine.routes.draw do
post "/extract" => "articles#extract" post "/extract" => "articles#extract"
end end
Discourse::Application.routes.append do Discourse::Application.routes.append do
mount UrlToArticle::Engine, at: "/url-to-article" mount BookmarkUrl::Engine, at: "/bookmark-url"
end end
end end